Compare commits
2 commits
6a65354f4c
...
6c6d683b9a
| Author | SHA1 | Date | |
|---|---|---|---|
| 6c6d683b9a | |||
| 9262132325 |
68 changed files with 2755 additions and 14231 deletions
|
|
@ -62,7 +62,7 @@ services:
|
||||||
MYSQL_EXTRA_OPTIONS: --default-authentication-plugin=mysql_native_password
|
MYSQL_EXTRA_OPTIONS: --default-authentication-plugin=mysql_native_password
|
||||||
volumes:
|
volumes:
|
||||||
- '../:/var/www/html'
|
- '../:/var/www/html'
|
||||||
- './php-upload-limits.ini:/etc/php/8.4/cli/conf.d/99-upload-limits.ini:ro'
|
- './php-upload-limits.ini:/etc/php/8.4/cli/conf.d/100-b2in-upload-limits.ini:ro'
|
||||||
networks:
|
networks:
|
||||||
- sail
|
- sail
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,4 @@
|
||||||
|
[PHP]
|
||||||
|
; Muss über Sail-Standard (99-sail.ini: 100M) liegen; siehe Display-Mediathek / Livewire-Uploads (~200 MB).
|
||||||
upload_max_filesize = 210M
|
upload_max_filesize = 210M
|
||||||
post_max_size = 210M
|
post_max_size = 210M
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,12 @@ AWS_USE_PATH_STYLE_ENDPOINT=false
|
||||||
|
|
||||||
VITE_APP_NAME="${APP_NAME}"
|
VITE_APP_NAME="${APP_NAME}"
|
||||||
|
|
||||||
|
# Display-Player URL (Basis-URL der Player-Seite ohne ?id=).
|
||||||
|
# Live: https://cabinet.b2in.eu/display
|
||||||
|
# Lokal: https://portal.b2in.test/_cabinet/display (Player-Seite + API laufen auf der Portal-Domain)
|
||||||
|
# Ohne Wert greift der Produktions-Fallback aus config/display.php.
|
||||||
|
DISPLAY_PLAYER_URL=https://portal.b2in.test/_cabinet/display
|
||||||
|
|
||||||
# Cookie Consent & Google Analytics (acme/cookie-consent)
|
# Cookie Consent & Google Analytics (acme/cookie-consent)
|
||||||
# GOOGLE_ANALYTICS_ID=G-XXXXXXXXXX
|
# GOOGLE_ANALYTICS_ID=G-XXXXXXXXXX
|
||||||
# GOOGLE_TAG_MANAGER_ID=GTM-XXXXXXXX
|
# GOOGLE_TAG_MANAGER_ID=GTM-XXXXXXXX
|
||||||
|
|
|
||||||
59
app/Console/Commands/GenerateVideoThumbnails.php
Normal file
59
app/Console/Commands/GenerateVideoThumbnails.php
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\DisplayMedia;
|
||||||
|
use App\Services\DisplayMediaService;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
class GenerateVideoThumbnails extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'display-media:generate-video-thumbnails
|
||||||
|
{--force : Regenerate posters even if a thumbnail already exists}';
|
||||||
|
|
||||||
|
protected $description = 'Generate poster frames for uploaded display media videos using ffmpeg';
|
||||||
|
|
||||||
|
public function handle(DisplayMediaService $service): int
|
||||||
|
{
|
||||||
|
$query = DisplayMedia::query()
|
||||||
|
->where('type', 'video')
|
||||||
|
->where('source_type', 'upload');
|
||||||
|
|
||||||
|
if (! $this->option('force')) {
|
||||||
|
$query->whereNull('thumbnail_path');
|
||||||
|
}
|
||||||
|
|
||||||
|
$videos = $query->get();
|
||||||
|
|
||||||
|
if ($videos->isEmpty()) {
|
||||||
|
$this->info('Keine Videos zum Verarbeiten gefunden.');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info(sprintf('%d Video(s) werden verarbeitet...', $videos->count()));
|
||||||
|
|
||||||
|
$generated = 0;
|
||||||
|
$failed = 0;
|
||||||
|
|
||||||
|
foreach ($videos as $video) {
|
||||||
|
$thumbnailPath = $service->generateVideoThumbnail($video);
|
||||||
|
|
||||||
|
if ($thumbnailPath !== null) {
|
||||||
|
$video->update(['thumbnail_path' => $thumbnailPath]);
|
||||||
|
$this->line(" <info>✓</info> {$video->getDisplayName()}");
|
||||||
|
$generated++;
|
||||||
|
} else {
|
||||||
|
$this->warn(" ✗ {$video->getDisplayName()} (Poster konnte nicht erzeugt werden)");
|
||||||
|
$failed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->newLine();
|
||||||
|
$this->info(sprintf('Fertig: %d erzeugt, %d fehlgeschlagen.', $generated, $failed));
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,8 @@ namespace App\Console\Commands;
|
||||||
|
|
||||||
use App\Models\Display;
|
use App\Models\Display;
|
||||||
use App\Models\DisplayFooterContent;
|
use App\Models\DisplayFooterContent;
|
||||||
|
use App\Models\DisplayPlaylist;
|
||||||
|
use App\Models\DisplayPlaylistItem;
|
||||||
use App\Models\DisplayVersion;
|
use App\Models\DisplayVersion;
|
||||||
use App\Models\DisplayVersionItem;
|
use App\Models\DisplayVersionItem;
|
||||||
use App\Models\DisplayVideo;
|
use App\Models\DisplayVideo;
|
||||||
|
|
@ -75,7 +77,16 @@ class MigrateLegacyDisplays extends Command
|
||||||
'is_active' => true,
|
'is_active' => true,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$display->versions()->attach($version->id, ['sort_order' => 0]);
|
$playlist = $display->playlists()->create([
|
||||||
|
'status' => DisplayPlaylist::STATUS_PUBLISHED,
|
||||||
|
'published_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
DisplayPlaylistItem::create([
|
||||||
|
'display_playlist_id' => $playlist->id,
|
||||||
|
'display_version_id' => $version->id,
|
||||||
|
'sort_order' => 0,
|
||||||
|
]);
|
||||||
|
|
||||||
$this->info("Migrated {$videos->count()} videos and {$footers->count()} footer items.");
|
$this->info("Migrated {$videos->count()} videos and {$footers->count()} footer items.");
|
||||||
$this->info("Created version: {$version->name} (ID: {$version->id})");
|
$this->info("Created version: {$version->name} (ID: {$version->id})");
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,9 @@ class DisplayPreviewController extends Controller
|
||||||
->where('preview_token', $token)
|
->where('preview_token', $token)
|
||||||
->firstOrFail();
|
->firstOrFail();
|
||||||
|
|
||||||
return response()->file(public_path('_cabinet/display/index.html'));
|
return response()->file(public_path('_cabinet/display/index.html'), [
|
||||||
|
'Cache-Control' => 'no-cache, must-revalidate',
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function config(string $token, DisplayPlaylistConfigBuilder $configBuilder): JsonResponse
|
public function config(string $token, DisplayPlaylistConfigBuilder $configBuilder): JsonResponse
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,35 @@ use Illuminate\Http\JsonResponse;
|
||||||
|
|
||||||
class DisplayVersionApiController extends Controller
|
class DisplayVersionApiController extends Controller
|
||||||
{
|
{
|
||||||
|
public function overview(): JsonResponse
|
||||||
|
{
|
||||||
|
$displays = Display::query()
|
||||||
|
->with(['livePlaylist.modules'])
|
||||||
|
->where('is_active', true)
|
||||||
|
->whereHas('livePlaylist.modules')
|
||||||
|
->orderBy('name')
|
||||||
|
->get()
|
||||||
|
->map(function (Display $display): array {
|
||||||
|
$playlist = $display->livePlaylist;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $display->id,
|
||||||
|
'name' => $display->name,
|
||||||
|
'location' => $display->location,
|
||||||
|
'is_active' => $display->is_active,
|
||||||
|
'is_live' => true,
|
||||||
|
'module_count' => $playlist?->modules->count() ?? 0,
|
||||||
|
'updated_at' => $playlist?->updated_at?->toIso8601String(),
|
||||||
|
'url' => rtrim(config('display.player_url'), '/').'/?id='.$display->id,
|
||||||
|
];
|
||||||
|
})
|
||||||
|
->values();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'displays' => $displays,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
public function config(Display $display, DisplayPlaylistConfigBuilder $configBuilder): JsonResponse
|
public function config(Display $display, DisplayPlaylistConfigBuilder $configBuilder): JsonResponse
|
||||||
{
|
{
|
||||||
if (! $display->is_active) {
|
if (! $display->is_active) {
|
||||||
|
|
|
||||||
|
|
@ -13,14 +13,21 @@ class ModulePreviewController extends Controller
|
||||||
{
|
{
|
||||||
public function show(DisplayVersion $module): BinaryFileResponse
|
public function show(DisplayVersion $module): BinaryFileResponse
|
||||||
{
|
{
|
||||||
return response()->file(public_path('_cabinet/display/index.html'));
|
return $this->playerResponse();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function showItem(DisplayVersion $module, DisplayVersionItem $item): BinaryFileResponse
|
public function showItem(DisplayVersion $module, DisplayVersionItem $item): BinaryFileResponse
|
||||||
{
|
{
|
||||||
abort_unless($item->display_version_id === $module->id, 404);
|
abort_unless($item->display_version_id === $module->id, 404);
|
||||||
|
|
||||||
return response()->file(public_path('_cabinet/display/index.html'));
|
return $this->playerResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function playerResponse(): BinaryFileResponse
|
||||||
|
{
|
||||||
|
return response()->file(public_path('_cabinet/display/index.html'), [
|
||||||
|
'Cache-Control' => 'no-cache, must-revalidate',
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function config(DisplayVersion $module, DisplayPlaylistConfigBuilder $configBuilder): JsonResponse
|
public function config(DisplayVersion $module, DisplayPlaylistConfigBuilder $configBuilder): JsonResponse
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,9 @@ class DisplayList extends Component
|
||||||
|
|
||||||
public $addVersionSelect = null;
|
public $addVersionSelect = null;
|
||||||
|
|
||||||
|
/** @var array<int> */
|
||||||
|
public $versionsToAdd = [];
|
||||||
|
|
||||||
public $editingPlaylistStatus = DisplayPlaylist::STATUS_PUBLISHED;
|
public $editingPlaylistStatus = DisplayPlaylist::STATUS_PUBLISHED;
|
||||||
|
|
||||||
public ?string $draftPreviewToken = null;
|
public ?string $draftPreviewToken = null;
|
||||||
|
|
@ -44,7 +47,7 @@ class DisplayList extends Component
|
||||||
], true) ? $playlistStatus : DisplayPlaylist::STATUS_PUBLISHED;
|
], true) ? $playlistStatus : DisplayPlaylist::STATUS_PUBLISHED;
|
||||||
|
|
||||||
if ($id) {
|
if ($id) {
|
||||||
$display = Display::with(['livePlaylist.modules', 'draftPlaylist.modules', 'versions'])->findOrFail($id);
|
$display = Display::with(['livePlaylist.modules', 'draftPlaylist.modules'])->findOrFail($id);
|
||||||
$this->displayId = $display->id;
|
$this->displayId = $display->id;
|
||||||
$this->displayName = $display->name;
|
$this->displayName = $display->name;
|
||||||
$this->displayLocation = $display->location ?? '';
|
$this->displayLocation = $display->location ?? '';
|
||||||
|
|
@ -75,6 +78,20 @@ class DisplayList extends Component
|
||||||
$this->persistDraftPreviewIfNeeded();
|
$this->persistDraftPreviewIfNeeded();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function addSelectedVersions(): void
|
||||||
|
{
|
||||||
|
foreach ($this->versionsToAdd as $versionId) {
|
||||||
|
$id = (int) $versionId;
|
||||||
|
|
||||||
|
if ($id && ! in_array($id, $this->selectedVersionIds, true)) {
|
||||||
|
$this->selectedVersionIds[] = $id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->versionsToAdd = [];
|
||||||
|
$this->persistDraftPreviewIfNeeded();
|
||||||
|
}
|
||||||
|
|
||||||
private function firstAvailableVersionId(): ?int
|
private function firstAvailableVersionId(): ?int
|
||||||
{
|
{
|
||||||
return DisplayVersion::active()
|
return DisplayVersion::active()
|
||||||
|
|
@ -137,7 +154,6 @@ class DisplayList extends Component
|
||||||
$this->previewFrameRefreshCounter++;
|
$this->previewFrameRefreshCounter++;
|
||||||
} else {
|
} else {
|
||||||
$this->syncPublishedPlaylist($display);
|
$this->syncPublishedPlaylist($display);
|
||||||
$this->syncLegacyPivot($display, $this->selectedVersionIds);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->closeModal();
|
$this->closeModal();
|
||||||
|
|
@ -176,10 +192,31 @@ class DisplayList extends Component
|
||||||
}
|
}
|
||||||
|
|
||||||
$display->draftPlaylist->delete();
|
$display->draftPlaylist->delete();
|
||||||
|
$display->clearPreviewToken();
|
||||||
|
|
||||||
session()->flash('success', 'Entwurf wurde verworfen.');
|
session()->flash('success', 'Entwurf wurde verworfen.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function rotatePreviewToken(int $displayId): void
|
||||||
|
{
|
||||||
|
$display = Display::with('draftPlaylist')->findOrFail($displayId);
|
||||||
|
|
||||||
|
if (! $display->draftPlaylist) {
|
||||||
|
session()->flash('success', 'Für dieses Display gibt es keinen Entwurf.');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$display->rotatePreviewToken();
|
||||||
|
|
||||||
|
if ($this->displayId === $display->id && $this->editingPlaylistStatus === DisplayPlaylist::STATUS_DRAFT) {
|
||||||
|
$this->draftPreviewToken = $display->preview_token;
|
||||||
|
$this->previewFrameRefreshCounter++;
|
||||||
|
}
|
||||||
|
|
||||||
|
session()->flash('success', 'Vorschau-Link wurde neu erzeugt. Der alte Link ist jetzt ungültig.');
|
||||||
|
}
|
||||||
|
|
||||||
public function publishDraft(int $displayId): void
|
public function publishDraft(int $displayId): void
|
||||||
{
|
{
|
||||||
$display = Display::with(['draftPlaylist.modules'])->findOrFail($displayId);
|
$display = Display::with(['draftPlaylist.modules'])->findOrFail($displayId);
|
||||||
|
|
@ -202,24 +239,11 @@ class DisplayList extends Component
|
||||||
return $display->draftPlaylist->fresh('modules');
|
return $display->draftPlaylist->fresh('modules');
|
||||||
});
|
});
|
||||||
|
|
||||||
$this->syncLegacyPivot($display, $this->moduleIdsForPlaylist($publishedPlaylist));
|
$display->clearPreviewToken();
|
||||||
|
|
||||||
session()->flash('success', 'Entwurf wurde veröffentlicht.');
|
session()->flash('success', 'Entwurf wurde veröffentlicht.');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<int> $versionIds
|
|
||||||
*/
|
|
||||||
private function syncLegacyPivot(Display $display, array $versionIds): void
|
|
||||||
{
|
|
||||||
$syncData = [];
|
|
||||||
foreach ($versionIds as $sortOrder => $versionId) {
|
|
||||||
$syncData[$versionId] = ['sort_order' => $sortOrder];
|
|
||||||
}
|
|
||||||
|
|
||||||
$display->versions()->sync($syncData);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function syncPublishedPlaylist(Display $display): void
|
private function syncPublishedPlaylist(Display $display): void
|
||||||
{
|
{
|
||||||
$playlist = $display->playlists()->firstOrCreate(
|
$playlist = $display->playlists()->firstOrCreate(
|
||||||
|
|
@ -297,8 +321,7 @@ class DisplayList extends Component
|
||||||
return $this->moduleIdsForPlaylist($display->draftPlaylist);
|
return $this->moduleIdsForPlaylist($display->draftPlaylist);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->moduleIdsForPlaylist($display->livePlaylist)
|
return $this->moduleIdsForPlaylist($display->livePlaylist);
|
||||||
?: $display->versions->pluck('id')->all();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function deleteDisplay(int $id): void
|
public function deleteDisplay(int $id): void
|
||||||
|
|
@ -331,6 +354,7 @@ class DisplayList extends Component
|
||||||
$this->displayIsActive = true;
|
$this->displayIsActive = true;
|
||||||
$this->displayIsTest = false;
|
$this->displayIsTest = false;
|
||||||
$this->addVersionSelect = null;
|
$this->addVersionSelect = null;
|
||||||
|
$this->versionsToAdd = [];
|
||||||
$this->editingPlaylistStatus = DisplayPlaylist::STATUS_PUBLISHED;
|
$this->editingPlaylistStatus = DisplayPlaylist::STATUS_PUBLISHED;
|
||||||
$this->draftPreviewToken = null;
|
$this->draftPreviewToken = null;
|
||||||
$this->previewFrameRefreshCounter = 0;
|
$this->previewFrameRefreshCounter = 0;
|
||||||
|
|
|
||||||
|
|
@ -117,8 +117,7 @@ class DisplayMediaPicker extends Component
|
||||||
->active()
|
->active()
|
||||||
->when($this->type === 'image', fn ($q) => $q->images())
|
->when($this->type === 'image', fn ($q) => $q->images())
|
||||||
->when($this->type === 'video', fn ($q) => $q->videos())
|
->when($this->type === 'video', fn ($q) => $q->videos())
|
||||||
->when($this->search, fn ($q) => $q->where('filename', 'like', "%{$this->search}%")
|
->when($this->search, fn ($q) => $q->search($this->search))
|
||||||
->orWhere('title', 'like', "%{$this->search}%"))
|
|
||||||
->orderByDesc('created_at')
|
->orderByDesc('created_at')
|
||||||
->paginate(18);
|
->paginate(18);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,10 @@
|
||||||
namespace App\Livewire\Admin\Cms;
|
namespace App\Livewire\Admin\Cms;
|
||||||
|
|
||||||
use App\Enums\DisplayVersionType;
|
use App\Enums\DisplayVersionType;
|
||||||
|
use App\Models\DisplayMedia;
|
||||||
use App\Models\DisplayVersion;
|
use App\Models\DisplayVersion;
|
||||||
use App\Models\DisplayVersionItem;
|
use App\Models\DisplayVersionItem;
|
||||||
use Illuminate\Support\Facades\File;
|
use App\Support\DisplayModuleSettings;
|
||||||
use Livewire\Attributes\On;
|
use Livewire\Attributes\On;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
|
|
||||||
|
|
@ -55,8 +56,12 @@ class DisplayVersionEditor extends Component
|
||||||
|
|
||||||
public bool $mediaIsActive = true;
|
public bool $mediaIsActive = true;
|
||||||
|
|
||||||
// Offers: Slide fields
|
// Offers: Slide fields (single dynamic detail layout)
|
||||||
public string $slideType = 'product-hero';
|
public bool $slideShowLogo = true;
|
||||||
|
|
||||||
|
public string $slideLogoUrl = '';
|
||||||
|
|
||||||
|
public string $slideBrandText = '';
|
||||||
|
|
||||||
public int $slideDuration = 8000;
|
public int $slideDuration = 8000;
|
||||||
|
|
||||||
|
|
@ -64,30 +69,46 @@ class DisplayVersionEditor extends Component
|
||||||
|
|
||||||
public string $slideBadge = '';
|
public string $slideBadge = '';
|
||||||
|
|
||||||
|
public bool $slideShowBadge = true;
|
||||||
|
|
||||||
public string $slideEyebrow = '';
|
public string $slideEyebrow = '';
|
||||||
|
|
||||||
|
public bool $slideShowEyebrow = true;
|
||||||
|
|
||||||
public string $slideTitle = '';
|
public string $slideTitle = '';
|
||||||
|
|
||||||
public string $slideSubline = '';
|
public string $slideSubline = '';
|
||||||
|
|
||||||
|
public bool $slideShowSubline = false;
|
||||||
|
|
||||||
public string $slidePrice = '';
|
public string $slidePrice = '';
|
||||||
|
|
||||||
public string $slideOriginalPrice = '';
|
public string $slideOriginalPrice = '';
|
||||||
|
|
||||||
|
public bool $slideStrikeOriginalPrice = false;
|
||||||
|
|
||||||
public string $slideTagText = '';
|
public string $slideTagText = '';
|
||||||
|
|
||||||
|
public bool $slideShowPrice = false;
|
||||||
|
|
||||||
/** @var array<string> */
|
/** @var array<string> */
|
||||||
public array $slideBullets = [];
|
public array $slideBullets = [];
|
||||||
|
|
||||||
|
public bool $slideShowBullets = true;
|
||||||
|
|
||||||
public string $slideDisclaimer = '';
|
public string $slideDisclaimer = '';
|
||||||
|
|
||||||
|
public bool $slideShowDisclaimer = false;
|
||||||
|
|
||||||
public string $slideQrUrl = '';
|
public string $slideQrUrl = '';
|
||||||
|
|
||||||
public string $slideQrTitle = '';
|
public string $slideQrTitle = '';
|
||||||
|
|
||||||
|
public bool $slideShowQr = true;
|
||||||
|
|
||||||
public string $slideContact = '';
|
public string $slideContact = '';
|
||||||
|
|
||||||
public bool $slideShowBrandText = false;
|
public bool $slideShowContact = true;
|
||||||
|
|
||||||
public string $slideBrandTagline = '';
|
public string $slideBrandTagline = '';
|
||||||
|
|
||||||
|
|
@ -98,32 +119,25 @@ class DisplayVersionEditor extends Component
|
||||||
|
|
||||||
public array $settings = [];
|
public array $settings = [];
|
||||||
|
|
||||||
/** @var array<string> */
|
|
||||||
public array $availableVideos = [];
|
|
||||||
|
|
||||||
public int $previewFrameRefreshCounter = 0;
|
public int $previewFrameRefreshCounter = 0;
|
||||||
|
|
||||||
|
/** @var array<string> */
|
||||||
|
public const BRAND_POSITIONS = ['top-left', 'top-right', 'bottom-left', 'bottom-right'];
|
||||||
|
|
||||||
public function mount(DisplayVersion $displayVersion): void
|
public function mount(DisplayVersion $displayVersion): void
|
||||||
{
|
{
|
||||||
$this->version = $displayVersion;
|
$this->version = $displayVersion;
|
||||||
$this->versionName = $displayVersion->name;
|
$this->versionName = $displayVersion->name;
|
||||||
$this->settings = $this->settingsWithDefaults();
|
$this->settings = $this->settingsWithDefaults();
|
||||||
|
$this->normalizeBrandPositions();
|
||||||
if ($this->version->type === DisplayVersionType::VideoDisplay) {
|
|
||||||
$this->loadAvailableVideos();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function loadAvailableVideos(): void
|
public function updated(string $name): void
|
||||||
{
|
{
|
||||||
$assetsPath = public_path('_cabinet/assets');
|
if (str_starts_with($name, 'settings.show_footer')
|
||||||
|
|| str_starts_with($name, 'settings.logo_position')
|
||||||
if (File::exists($assetsPath)) {
|
|| str_starts_with($name, 'settings.claim_position')) {
|
||||||
$this->availableVideos = collect(File::files($assetsPath))
|
$this->normalizeBrandPositions();
|
||||||
->map(fn ($file) => $file->getFilename())
|
|
||||||
->filter(fn ($filename) => in_array(pathinfo($filename, PATHINFO_EXTENSION), ['mp4', 'webm', 'mov']))
|
|
||||||
->values()
|
|
||||||
->toArray();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -159,12 +173,56 @@ class DisplayVersionEditor extends Component
|
||||||
|
|
||||||
public function saveSettings(): void
|
public function saveSettings(): void
|
||||||
{
|
{
|
||||||
|
$this->normalizeBrandPositions();
|
||||||
$this->version->update(['settings' => $this->settings]);
|
$this->version->update(['settings' => $this->settings]);
|
||||||
$this->showSettingsModal = false;
|
$this->showSettingsModal = false;
|
||||||
$this->refreshModulePreview();
|
$this->refreshModulePreview();
|
||||||
session()->flash('success', 'Einstellungen gespeichert!');
|
session()->flash('success', 'Einstellungen gespeichert!');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep the B2in logo/claim corners consistent:
|
||||||
|
* - bottom corners are only valid while the footer is hidden,
|
||||||
|
* - the claim can never sit in the same corner as the logo.
|
||||||
|
*/
|
||||||
|
private function normalizeBrandPositions(): void
|
||||||
|
{
|
||||||
|
if ($this->version->type !== DisplayVersionType::B2in) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$footerShown = ($this->settings['show_footer'] ?? true) !== false;
|
||||||
|
$allowed = $footerShown ? ['top-left', 'top-right'] : self::BRAND_POSITIONS;
|
||||||
|
|
||||||
|
$logo = $this->settings['logo_position'] ?? 'top-left';
|
||||||
|
$claim = $this->settings['claim_position'] ?? 'top-right';
|
||||||
|
|
||||||
|
$logo = $this->moveIntoAllowed($logo, $allowed);
|
||||||
|
$claim = $this->moveIntoAllowed($claim, $allowed);
|
||||||
|
|
||||||
|
if ($claim === $logo) {
|
||||||
|
$claim = collect($allowed)->first(fn (string $position) => $position !== $logo) ?? $claim;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->settings['logo_position'] = $logo;
|
||||||
|
$this->settings['claim_position'] = $claim;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string> $allowed
|
||||||
|
*/
|
||||||
|
private function moveIntoAllowed(string $position, array $allowed): string
|
||||||
|
{
|
||||||
|
if (in_array($position, $allowed, true)) {
|
||||||
|
return $position;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pull bottom corners up to the matching top corner when forbidden.
|
||||||
|
$fallback = str_replace('bottom-', 'top-', $position);
|
||||||
|
|
||||||
|
return in_array($fallback, $allowed, true) ? $fallback : ($allowed[0] ?? 'top-left');
|
||||||
|
}
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// ITEM CRUD
|
// ITEM CRUD
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
@ -275,10 +333,20 @@ class DisplayVersionEditor extends Component
|
||||||
'videoFilename' => $this->videoFilename = $url,
|
'videoFilename' => $this->videoFilename = $url,
|
||||||
'mediaUrl' => $this->mediaUrl = $url,
|
'mediaUrl' => $this->mediaUrl = $url,
|
||||||
'slideImageUrl' => $this->slideImageUrl = $url,
|
'slideImageUrl' => $this->slideImageUrl = $url,
|
||||||
|
'slideLogoUrl' => $this->slideLogoUrl = $url,
|
||||||
'settings.header_logo_url' => $this->settings['header_logo_url'] = $url,
|
'settings.header_logo_url' => $this->settings['header_logo_url'] = $url,
|
||||||
'settings.logo_url' => $this->settings['logo_url'] = $url,
|
|
||||||
default => null,
|
default => null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// The media type for a B2in playlist item is derived from the chosen
|
||||||
|
// media – the type selector in the form is only an informational hint.
|
||||||
|
if ($field === 'mediaUrl' && $mediaId) {
|
||||||
|
$media = DisplayMedia::find($mediaId);
|
||||||
|
|
||||||
|
if ($media) {
|
||||||
|
$this->mediaType = $media->isVideo() ? 'video' : 'image';
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function addBullet(): void
|
public function addBullet(): void
|
||||||
|
|
@ -305,33 +373,43 @@ class DisplayVersionEditor extends Component
|
||||||
private function loadItemContent(DisplayVersionItem $item): void
|
private function loadItemContent(DisplayVersionItem $item): void
|
||||||
{
|
{
|
||||||
$content = $item->content;
|
$content = $item->content;
|
||||||
|
$isActive = (bool) $item->is_active;
|
||||||
|
|
||||||
match ($item->item_type) {
|
match ($item->item_type) {
|
||||||
'video' => $this->loadVideoContent($content),
|
'video' => $this->loadVideoContent($content, $isActive),
|
||||||
'footer' => $this->loadFooterContent($content),
|
'footer' => $this->loadFooterContent($content, $isActive),
|
||||||
'media' => $this->loadMediaContent($content),
|
'media' => $this->loadMediaContent($content, $isActive),
|
||||||
'slide' => $this->loadSlideContent($content),
|
'slide' => $this->loadSlideContent($content, $isActive),
|
||||||
default => null,
|
default => null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private function loadVideoContent(array $content): void
|
/**
|
||||||
|
* @param array<string, mixed> $content
|
||||||
|
*/
|
||||||
|
private function loadVideoContent(array $content, bool $isActive): void
|
||||||
{
|
{
|
||||||
$this->videoFilename = $content['filename'] ?? '';
|
$this->videoFilename = $content['filename'] ?? '';
|
||||||
$this->videoTitle = $content['title'] ?? '';
|
$this->videoTitle = $content['title'] ?? '';
|
||||||
$this->videoPosition = $content['position'] ?? 25;
|
$this->videoPosition = $content['position'] ?? 25;
|
||||||
$this->videoIsActive = true;
|
$this->videoIsActive = $isActive;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function loadFooterContent(array $content): void
|
/**
|
||||||
|
* @param array<string, mixed> $content
|
||||||
|
*/
|
||||||
|
private function loadFooterContent(array $content, bool $isActive): void
|
||||||
{
|
{
|
||||||
$this->footerHeadline = $content['headline'] ?? '';
|
$this->footerHeadline = $content['headline'] ?? '';
|
||||||
$this->footerSubline = $content['subline'] ?? '';
|
$this->footerSubline = $content['subline'] ?? '';
|
||||||
$this->footerUrl = $content['url'] ?? '';
|
$this->footerUrl = $content['url'] ?? '';
|
||||||
$this->footerIsActive = true;
|
$this->footerIsActive = $isActive;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function loadMediaContent(array $content): void
|
/**
|
||||||
|
* @param array<string, mixed> $content
|
||||||
|
*/
|
||||||
|
private function loadMediaContent(array $content, bool $isActive): void
|
||||||
{
|
{
|
||||||
$this->mediaType = $content['media_type'] ?? 'image';
|
$this->mediaType = $content['media_type'] ?? 'image';
|
||||||
$this->mediaCategory = $content['category'] ?? 'immobilien';
|
$this->mediaCategory = $content['category'] ?? 'immobilien';
|
||||||
|
|
@ -339,12 +417,17 @@ class DisplayVersionEditor extends Component
|
||||||
$this->mediaHeadline = $content['headline'] ?? '';
|
$this->mediaHeadline = $content['headline'] ?? '';
|
||||||
$this->mediaSubline = $content['subline'] ?? '';
|
$this->mediaSubline = $content['subline'] ?? '';
|
||||||
$this->mediaDuration = $content['duration_seconds'] ?? 10;
|
$this->mediaDuration = $content['duration_seconds'] ?? 10;
|
||||||
$this->mediaIsActive = true;
|
$this->mediaIsActive = $isActive;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function loadSlideContent(array $content): void
|
/**
|
||||||
|
* @param array<string, mixed> $content
|
||||||
|
*/
|
||||||
|
private function loadSlideContent(array $content, bool $isActive): void
|
||||||
{
|
{
|
||||||
$this->slideType = $content['type'] ?? 'product-hero';
|
$this->slideShowLogo = $content['show_logo'] ?? true;
|
||||||
|
$this->slideLogoUrl = $content['logo_url'] ?? '';
|
||||||
|
$this->slideBrandText = $content['brand_text'] ?? '';
|
||||||
$this->slideDuration = $content['duration'] ?? 8000;
|
$this->slideDuration = $content['duration'] ?? 8000;
|
||||||
$this->slideImageUrl = $content['image_url'] ?? '';
|
$this->slideImageUrl = $content['image_url'] ?? '';
|
||||||
$this->slideBadge = $content['badge_text'] ?? '';
|
$this->slideBadge = $content['badge_text'] ?? '';
|
||||||
|
|
@ -353,15 +436,26 @@ class DisplayVersionEditor extends Component
|
||||||
$this->slideSubline = $content['subline'] ?? '';
|
$this->slideSubline = $content['subline'] ?? '';
|
||||||
$this->slidePrice = $content['price'] ?? '';
|
$this->slidePrice = $content['price'] ?? '';
|
||||||
$this->slideOriginalPrice = $content['original_price'] ?? '';
|
$this->slideOriginalPrice = $content['original_price'] ?? '';
|
||||||
|
$this->slideStrikeOriginalPrice = $content['strike_original_price'] ?? false;
|
||||||
$this->slideTagText = $content['tag_text'] ?? '';
|
$this->slideTagText = $content['tag_text'] ?? '';
|
||||||
$this->slideBullets = $content['bullets'] ?? [];
|
$this->slideBullets = $content['bullets'] ?? [];
|
||||||
$this->slideDisclaimer = $content['disclaimer'] ?? '';
|
$this->slideDisclaimer = $content['disclaimer'] ?? '';
|
||||||
$this->slideQrUrl = $content['qr_url'] ?? '';
|
$this->slideQrUrl = $content['qr_url'] ?? '';
|
||||||
$this->slideQrTitle = $content['qr_title'] ?? '';
|
$this->slideQrTitle = $content['qr_title'] ?? '';
|
||||||
$this->slideContact = $content['contact'] ?? '';
|
$this->slideContact = $content['contact'] ?? '';
|
||||||
$this->slideShowBrandText = $content['show_brand_text'] ?? false;
|
|
||||||
$this->slideBrandTagline = $content['brand_tagline'] ?? '';
|
$this->slideBrandTagline = $content['brand_tagline'] ?? '';
|
||||||
$this->slideIsActive = true;
|
$this->slideIsActive = $isActive;
|
||||||
|
|
||||||
|
// Show flags fall back to "is there content?" so slides created before
|
||||||
|
// the dynamic detail layout keep rendering exactly as they did.
|
||||||
|
$this->slideShowBadge = $content['show_badge'] ?? ($content['badge_text'] ?? '') !== '';
|
||||||
|
$this->slideShowEyebrow = $content['show_eyebrow'] ?? ($content['eyebrow'] ?? '') !== '';
|
||||||
|
$this->slideShowSubline = $content['show_subline'] ?? ($content['subline'] ?? '') !== '';
|
||||||
|
$this->slideShowBullets = $content['show_bullets'] ?? ! empty($content['bullets']);
|
||||||
|
$this->slideShowPrice = $content['show_price'] ?? ($content['price'] ?? '') !== '';
|
||||||
|
$this->slideShowDisclaimer = $content['show_disclaimer'] ?? ($content['disclaimer'] ?? '') !== '';
|
||||||
|
$this->slideShowQr = $content['show_qr'] ?? ($content['qr_url'] ?? '') !== '';
|
||||||
|
$this->slideShowContact = $content['show_contact'] ?? ($content['contact'] ?? '') !== '';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -389,22 +483,33 @@ class DisplayVersionEditor extends Component
|
||||||
'duration_seconds' => $this->mediaDuration,
|
'duration_seconds' => $this->mediaDuration,
|
||||||
],
|
],
|
||||||
'slide' => [
|
'slide' => [
|
||||||
'type' => $this->slideType,
|
'type' => 'detail',
|
||||||
|
'show_logo' => $this->slideShowLogo,
|
||||||
|
'logo_url' => $this->slideLogoUrl,
|
||||||
|
'brand_text' => $this->slideBrandText,
|
||||||
'duration' => $this->slideDuration,
|
'duration' => $this->slideDuration,
|
||||||
'image_url' => $this->slideImageUrl,
|
'image_url' => $this->slideImageUrl,
|
||||||
'badge_text' => $this->slideBadge,
|
'badge_text' => $this->slideBadge,
|
||||||
|
'show_badge' => $this->slideShowBadge,
|
||||||
'eyebrow' => $this->slideEyebrow,
|
'eyebrow' => $this->slideEyebrow,
|
||||||
|
'show_eyebrow' => $this->slideShowEyebrow,
|
||||||
'title' => $this->slideTitle,
|
'title' => $this->slideTitle,
|
||||||
'subline' => $this->slideSubline,
|
'subline' => $this->slideSubline,
|
||||||
|
'show_subline' => $this->slideShowSubline,
|
||||||
'price' => $this->slidePrice,
|
'price' => $this->slidePrice,
|
||||||
'original_price' => $this->slideOriginalPrice,
|
'original_price' => $this->slideOriginalPrice,
|
||||||
|
'strike_original_price' => $this->slideStrikeOriginalPrice,
|
||||||
'tag_text' => $this->slideTagText,
|
'tag_text' => $this->slideTagText,
|
||||||
'bullets' => $this->slideBullets,
|
'show_price' => $this->slideShowPrice,
|
||||||
|
'bullets' => array_values(array_filter($this->slideBullets, fn (string $bullet) => trim($bullet) !== '')),
|
||||||
|
'show_bullets' => $this->slideShowBullets,
|
||||||
'disclaimer' => $this->slideDisclaimer,
|
'disclaimer' => $this->slideDisclaimer,
|
||||||
|
'show_disclaimer' => $this->slideShowDisclaimer,
|
||||||
'qr_url' => $this->slideQrUrl,
|
'qr_url' => $this->slideQrUrl,
|
||||||
'qr_title' => $this->slideQrTitle,
|
'qr_title' => $this->slideQrTitle,
|
||||||
|
'show_qr' => $this->slideShowQr,
|
||||||
'contact' => $this->slideContact,
|
'contact' => $this->slideContact,
|
||||||
'show_brand_text' => $this->slideShowBrandText,
|
'show_contact' => $this->slideShowContact,
|
||||||
'brand_tagline' => $this->slideBrandTagline,
|
'brand_tagline' => $this->slideBrandTagline,
|
||||||
],
|
],
|
||||||
default => [],
|
default => [],
|
||||||
|
|
@ -450,22 +555,32 @@ class DisplayVersionEditor extends Component
|
||||||
$this->mediaSubline = '';
|
$this->mediaSubline = '';
|
||||||
$this->mediaDuration = 10;
|
$this->mediaDuration = 10;
|
||||||
$this->mediaIsActive = true;
|
$this->mediaIsActive = true;
|
||||||
$this->slideType = 'product-hero';
|
$this->slideShowLogo = true;
|
||||||
|
$this->slideLogoUrl = '';
|
||||||
|
$this->slideBrandText = '';
|
||||||
$this->slideDuration = 8000;
|
$this->slideDuration = 8000;
|
||||||
$this->slideImageUrl = '';
|
$this->slideImageUrl = '';
|
||||||
$this->slideBadge = '';
|
$this->slideBadge = '';
|
||||||
|
$this->slideShowBadge = true;
|
||||||
$this->slideEyebrow = '';
|
$this->slideEyebrow = '';
|
||||||
|
$this->slideShowEyebrow = true;
|
||||||
$this->slideTitle = '';
|
$this->slideTitle = '';
|
||||||
$this->slideSubline = '';
|
$this->slideSubline = '';
|
||||||
|
$this->slideShowSubline = false;
|
||||||
$this->slidePrice = '';
|
$this->slidePrice = '';
|
||||||
$this->slideOriginalPrice = '';
|
$this->slideOriginalPrice = '';
|
||||||
|
$this->slideStrikeOriginalPrice = false;
|
||||||
$this->slideTagText = '';
|
$this->slideTagText = '';
|
||||||
|
$this->slideShowPrice = false;
|
||||||
$this->slideBullets = [];
|
$this->slideBullets = [];
|
||||||
|
$this->slideShowBullets = true;
|
||||||
$this->slideDisclaimer = '';
|
$this->slideDisclaimer = '';
|
||||||
|
$this->slideShowDisclaimer = false;
|
||||||
$this->slideQrUrl = '';
|
$this->slideQrUrl = '';
|
||||||
$this->slideQrTitle = '';
|
$this->slideQrTitle = '';
|
||||||
|
$this->slideShowQr = true;
|
||||||
$this->slideContact = '';
|
$this->slideContact = '';
|
||||||
$this->slideShowBrandText = false;
|
$this->slideShowContact = true;
|
||||||
$this->slideBrandTagline = '';
|
$this->slideBrandTagline = '';
|
||||||
$this->slideIsActive = true;
|
$this->slideIsActive = true;
|
||||||
}
|
}
|
||||||
|
|
@ -480,46 +595,7 @@ class DisplayVersionEditor extends Component
|
||||||
*/
|
*/
|
||||||
private function settingsWithDefaults(): array
|
private function settingsWithDefaults(): array
|
||||||
{
|
{
|
||||||
return array_replace_recursive($this->defaultSettings(), $this->version->settings ?? []);
|
return DisplayModuleSettings::merge($this->version->type, $this->version->settings);
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
private function defaultSettings(): array
|
|
||||||
{
|
|
||||||
return match ($this->version->type) {
|
|
||||||
DisplayVersionType::VideoDisplay => [
|
|
||||||
'qr_label' => 'Website',
|
|
||||||
],
|
|
||||||
DisplayVersionType::B2in => [
|
|
||||||
'theme' => 'dark',
|
|
||||||
'header_logo_url' => '../assets/b2in-logo-positive.svg',
|
|
||||||
'header_claim' => 'Connecting Design & Property',
|
|
||||||
'footer_url' => 'B2in.eu',
|
|
||||||
'footer_name' => '',
|
|
||||||
'footer_prefix' => 'by',
|
|
||||||
'qr_url' => '',
|
|
||||||
'transition' => [
|
|
||||||
'type' => 'crossfade',
|
|
||||||
'duration_ms' => 800,
|
|
||||||
],
|
|
||||||
'default_image_duration' => 10,
|
|
||||||
],
|
|
||||||
DisplayVersionType::Offers => [
|
|
||||||
'loop' => true,
|
|
||||||
'logo_url' => '../logo-cabinet-300.png',
|
|
||||||
'brand_text' => 'Bielefeld',
|
|
||||||
'footer_claim' => '',
|
|
||||||
'footer_url' => '',
|
|
||||||
'qr_default_title' => 'Kontakt',
|
|
||||||
'qr_subtitle' => 'QR scannen',
|
|
||||||
'transition' => [
|
|
||||||
'type' => 'fade',
|
|
||||||
'duration' => 600,
|
|
||||||
],
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function render()
|
public function render()
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ namespace App\Livewire\Admin\Cms;
|
||||||
|
|
||||||
use App\Enums\DisplayVersionType;
|
use App\Enums\DisplayVersionType;
|
||||||
use App\Models\DisplayVersion;
|
use App\Models\DisplayVersion;
|
||||||
|
use App\Support\DisplayModuleSettings;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
|
|
||||||
class DisplayVersionList extends Component
|
class DisplayVersionList extends Component
|
||||||
|
|
@ -52,7 +54,20 @@ class DisplayVersionList extends Component
|
||||||
|
|
||||||
public function deleteVersion(int $id): void
|
public function deleteVersion(int $id): void
|
||||||
{
|
{
|
||||||
$version = DisplayVersion::findOrFail($id);
|
$version = DisplayVersion::query()
|
||||||
|
->withCount([
|
||||||
|
'playlistItems as displays_count' => fn ($query) => $query
|
||||||
|
->join('display_playlists', 'display_playlist_items.display_playlist_id', '=', 'display_playlists.id')
|
||||||
|
->select(DB::raw('count(distinct display_playlists.display_id)')),
|
||||||
|
])
|
||||||
|
->findOrFail($id);
|
||||||
|
|
||||||
|
if ($version->displays_count > 0) {
|
||||||
|
session()->flash('error', 'Modul "'.$version->name.'" wird noch von '.$version->displays_count.' Display(s) verwendet und kann nicht gelöscht werden. Entfernen Sie es zuerst aus den betroffenen Bespielungen.');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$name = $version->name;
|
$name = $version->name;
|
||||||
$version->delete();
|
$version->delete();
|
||||||
|
|
||||||
|
|
@ -70,40 +85,17 @@ class DisplayVersionList extends Component
|
||||||
*/
|
*/
|
||||||
private function defaultSettingsForType(string $type): array
|
private function defaultSettingsForType(string $type): array
|
||||||
{
|
{
|
||||||
return match ($type) {
|
return DisplayModuleSettings::defaults($type);
|
||||||
'b2in' => [
|
|
||||||
'theme' => 'dark',
|
|
||||||
'header_logo_url' => '../assets/b2in-logo-positive.svg',
|
|
||||||
'header_claim' => 'Connecting Design & Property',
|
|
||||||
'footer_name' => '',
|
|
||||||
'footer_url' => 'B2in.eu',
|
|
||||||
'footer_prefix' => 'by',
|
|
||||||
'qr_url' => '',
|
|
||||||
'transition' => ['type' => 'crossfade', 'duration_ms' => 800],
|
|
||||||
'default_image_duration' => 10,
|
|
||||||
'rotation_weights' => ['immobilien' => 70, 'moebel' => 30],
|
|
||||||
'display_active' => true,
|
|
||||||
],
|
|
||||||
'offers' => [
|
|
||||||
'loop' => true,
|
|
||||||
'logo_url' => '../logo-cabinet-300.png',
|
|
||||||
'brand_text' => 'Bielefeld',
|
|
||||||
'footer_claim' => '',
|
|
||||||
'footer_url' => '',
|
|
||||||
'qr_default_title' => 'Kontakt',
|
|
||||||
'qr_subtitle' => 'QR scannen',
|
|
||||||
'transition' => ['type' => 'fade', 'duration' => 600],
|
|
||||||
],
|
|
||||||
'video-display' => [
|
|
||||||
'qr_label' => 'Website',
|
|
||||||
],
|
|
||||||
default => [],
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function render()
|
public function render()
|
||||||
{
|
{
|
||||||
$versions = DisplayVersion::withCount(['items', 'displays'])
|
$versions = DisplayVersion::withCount([
|
||||||
|
'items',
|
||||||
|
'playlistItems as displays_count' => fn ($query) => $query
|
||||||
|
->join('display_playlists', 'display_playlist_items.display_playlist_id', '=', 'display_playlists.id')
|
||||||
|
->select(DB::raw('count(distinct display_playlists.display_id)')),
|
||||||
|
])
|
||||||
->orderBy('name')
|
->orderBy('name')
|
||||||
->get();
|
->get();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ class MediaLibraryUploader extends Component
|
||||||
{
|
{
|
||||||
$this->validate([
|
$this->validate([
|
||||||
'uploads' => 'nullable|array|max:20',
|
'uploads' => 'nullable|array|max:20',
|
||||||
'uploads.*' => 'file|mimes:jpeg,jpg,png,gif,webp,svg,pdf,doc,docx|max:10240',
|
'uploads.*' => 'file|mimes:jpeg,jpg,png,gif,webp,svg,pdf,doc,docx|max:204800',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$service = app(MediaConversionService::class);
|
$service = app(MediaConversionService::class);
|
||||||
|
|
|
||||||
|
|
@ -73,7 +73,7 @@ class MediaPicker extends Component
|
||||||
{
|
{
|
||||||
$this->validate([
|
$this->validate([
|
||||||
'quickUploads' => 'nullable|array|max:5',
|
'quickUploads' => 'nullable|array|max:5',
|
||||||
'quickUploads.*' => 'file|mimes:jpeg,jpg,png,gif,webp,svg,pdf,doc,docx|max:10240',
|
'quickUploads.*' => 'file|mimes:jpeg,jpg,png,gif,webp,svg,pdf,doc,docx|max:204800',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$service = app(MediaConversionService::class);
|
$service = app(MediaConversionService::class);
|
||||||
|
|
|
||||||
|
|
@ -45,10 +45,10 @@ class QuickStatus extends Component
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
public function mount(): void
|
public function mount(?string $k = null): void
|
||||||
{
|
{
|
||||||
$validKey = config('domains.cabinet_status_key');
|
$validKey = config('domains.cabinet_status_key');
|
||||||
$key = request()->get('key');
|
$key = $k ?? request()->query('k');
|
||||||
if (! $validKey || $key !== $validKey) {
|
if (! $validKey || $key !== $validKey) {
|
||||||
$this->authorized = false;
|
$this->authorized = false;
|
||||||
|
|
||||||
|
|
@ -92,6 +92,13 @@ class QuickStatus extends Component
|
||||||
'noticeSubtext.max' => 'Subtext max. 80 Zeichen.',
|
'noticeSubtext.max' => 'Subtext max. 80 Zeichen.',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$showsNotice = in_array($this->storeStatus, ['notice', 'warning'], true);
|
||||||
|
|
||||||
|
if (! $showsNotice) {
|
||||||
|
$this->noticeHeadline = '';
|
||||||
|
$this->noticeSubtext = '';
|
||||||
|
}
|
||||||
|
|
||||||
CabinetTabletSetting::current()->update([
|
CabinetTabletSetting::current()->update([
|
||||||
'store_status' => $this->storeStatus,
|
'store_status' => $this->storeStatus,
|
||||||
'notice_headline' => $this->noticeHeadline ?: null,
|
'notice_headline' => $this->noticeHeadline ?: null,
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ namespace App\Models;
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
|
@ -30,17 +29,6 @@ class Display extends Model
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @deprecated Wird in Phase 7 entfernt. Nutze stattdessen liveModules()
|
|
||||||
* oder die Playlist-Relationen (livePlaylist, draftPlaylist).
|
|
||||||
*/
|
|
||||||
public function versions(): BelongsToMany
|
|
||||||
{
|
|
||||||
return $this->belongsToMany(DisplayVersion::class, 'display_display_version')
|
|
||||||
->withPivot('sort_order')
|
|
||||||
->orderByPivot('sort_order');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return HasMany<DisplayPlaylist, $this>
|
* @return HasMany<DisplayPlaylist, $this>
|
||||||
*/
|
*/
|
||||||
|
|
@ -67,29 +55,6 @@ class Display extends Model
|
||||||
->where('status', DisplayPlaylist::STATUS_DRAFT);
|
->where('status', DisplayPlaylist::STATUS_DRAFT);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Liefert die Module der aktuell veröffentlichten Bespielung in Reihenfolge.
|
|
||||||
*/
|
|
||||||
public function liveModules(): BelongsToMany
|
|
||||||
{
|
|
||||||
return $this->belongsToMany(
|
|
||||||
DisplayVersion::class,
|
|
||||||
'display_playlist_items',
|
|
||||||
'display_playlist_id',
|
|
||||||
'display_version_id'
|
|
||||||
)
|
|
||||||
->wherePivotIn(
|
|
||||||
'display_playlist_id',
|
|
||||||
DisplayPlaylist::query()
|
|
||||||
->where('display_id', $this->id ?? 0)
|
|
||||||
->where('status', DisplayPlaylist::STATUS_PUBLISHED)
|
|
||||||
->select('id')
|
|
||||||
)
|
|
||||||
->withPivot(['sort_order', 'id'])
|
|
||||||
->withTimestamps()
|
|
||||||
->orderByPivot('sort_order');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function ensurePreviewToken(): string
|
public function ensurePreviewToken(): string
|
||||||
{
|
{
|
||||||
if (! $this->preview_token) {
|
if (! $this->preview_token) {
|
||||||
|
|
@ -99,4 +64,20 @@ class Display extends Model
|
||||||
|
|
||||||
return $this->preview_token;
|
return $this->preview_token;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function rotatePreviewToken(): string
|
||||||
|
{
|
||||||
|
$this->preview_token = Str::random(40);
|
||||||
|
$this->save();
|
||||||
|
|
||||||
|
return $this->preview_token;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function clearPreviewToken(): void
|
||||||
|
{
|
||||||
|
if ($this->preview_token !== null) {
|
||||||
|
$this->preview_token = null;
|
||||||
|
$this->save();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -150,4 +150,12 @@ class DisplayMedia extends Model
|
||||||
{
|
{
|
||||||
return $query->where('collection', $collection);
|
return $query->where('collection', $collection);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function scopeSearch(Builder $query, string $term): Builder
|
||||||
|
{
|
||||||
|
return $query->where(function (Builder $query) use ($term): void {
|
||||||
|
$query->where('filename', 'like', "%{$term}%")
|
||||||
|
->orWhere('title', 'like', "%{$term}%");
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ use App\Enums\DisplayVersionType;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
class DisplayVersion extends Model
|
class DisplayVersion extends Model
|
||||||
|
|
@ -35,10 +34,12 @@ class DisplayVersion extends Model
|
||||||
return $this->hasMany(DisplayVersionItem::class)->orderBy('sort_order');
|
return $this->hasMany(DisplayVersionItem::class)->orderBy('sort_order');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function displays(): BelongsToMany
|
/**
|
||||||
|
* @return HasMany<DisplayPlaylistItem, $this>
|
||||||
|
*/
|
||||||
|
public function playlistItems(): HasMany
|
||||||
{
|
{
|
||||||
return $this->belongsToMany(Display::class, 'display_display_version')
|
return $this->hasMany(DisplayPlaylistItem::class);
|
||||||
->withPivot('sort_order');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ use Illuminate\Http\UploadedFile;
|
||||||
use Illuminate\Support\Facades\Http;
|
use Illuminate\Support\Facades\Http;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
use Symfony\Component\Process\Process;
|
||||||
|
|
||||||
class DisplayMediaService
|
class DisplayMediaService
|
||||||
{
|
{
|
||||||
|
|
@ -34,7 +35,7 @@ class DisplayMediaService
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return DisplayMedia::create([
|
$media = DisplayMedia::create([
|
||||||
'filename' => $filename,
|
'filename' => $filename,
|
||||||
'disk' => 'public',
|
'disk' => 'public',
|
||||||
'path' => $relativePath,
|
'path' => $relativePath,
|
||||||
|
|
@ -45,6 +46,80 @@ class DisplayMediaService
|
||||||
'collection' => $collection,
|
'collection' => $collection,
|
||||||
'metadata' => ! empty($metadata) ? $metadata : null,
|
'metadata' => ! empty($metadata) ? $metadata : null,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
if ($type === 'video') {
|
||||||
|
$thumbnailPath = $this->generateVideoThumbnail($media);
|
||||||
|
|
||||||
|
if ($thumbnailPath !== null) {
|
||||||
|
$media->update(['thumbnail_path' => $thumbnailPath]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $media;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a poster frame for an uploaded video using ffmpeg.
|
||||||
|
*
|
||||||
|
* Returns the relative thumbnail path on the media's disk, or null when
|
||||||
|
* generation is not possible (e.g. ffmpeg missing or an unreadable file).
|
||||||
|
*/
|
||||||
|
public function generateVideoThumbnail(DisplayMedia $media): ?string
|
||||||
|
{
|
||||||
|
if (! $media->isVideo() || ! $media->isUpload() || ! $media->path) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$disk = Storage::disk($media->disk);
|
||||||
|
$videoPath = $disk->path($media->path);
|
||||||
|
|
||||||
|
if (! is_file($videoPath)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$thumbnailRelativePath = preg_replace('/\.[^.\/]+$/', '', $media->path).'-poster.jpg';
|
||||||
|
$thumbnailPath = $disk->path($thumbnailRelativePath);
|
||||||
|
|
||||||
|
if (! is_dir(dirname($thumbnailPath))) {
|
||||||
|
@mkdir(dirname($thumbnailPath), 0755, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try a frame ~1s in first (avoids black intro frames); fall back to
|
||||||
|
// the very first frame for clips shorter than one second.
|
||||||
|
foreach (['1', '0'] as $seekSeconds) {
|
||||||
|
if ($this->extractFrame($videoPath, $thumbnailPath, $seekSeconds) && filesize($thumbnailPath) > 0) {
|
||||||
|
return $thumbnailRelativePath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_file($thumbnailPath)) {
|
||||||
|
@unlink($thumbnailPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function extractFrame(string $videoPath, string $thumbnailPath, string $seekSeconds): bool
|
||||||
|
{
|
||||||
|
$process = new Process([
|
||||||
|
'ffmpeg',
|
||||||
|
'-y',
|
||||||
|
'-ss', $seekSeconds,
|
||||||
|
'-i', $videoPath,
|
||||||
|
'-frames:v', '1',
|
||||||
|
'-vf', 'scale=640:-2',
|
||||||
|
'-q:v', '3',
|
||||||
|
$thumbnailPath,
|
||||||
|
]);
|
||||||
|
$process->setTimeout(60);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$process->run();
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $process->isSuccessful() && is_file($thumbnailPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ namespace App\Services;
|
||||||
|
|
||||||
use App\Models\DisplayPlaylist;
|
use App\Models\DisplayPlaylist;
|
||||||
use App\Models\DisplayVersion;
|
use App\Models\DisplayVersion;
|
||||||
|
use App\Support\DisplayModuleSettings;
|
||||||
use Illuminate\Database\Eloquent\Collection;
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
|
|
||||||
class DisplayPlaylistConfigBuilder
|
class DisplayPlaylistConfigBuilder
|
||||||
|
|
@ -95,9 +96,7 @@ class DisplayPlaylistConfigBuilder
|
||||||
return [
|
return [
|
||||||
'type' => 'video-display',
|
'type' => 'video-display',
|
||||||
'version_name' => $module->name,
|
'version_name' => $module->name,
|
||||||
'settings' => array_replace([
|
'settings' => DisplayModuleSettings::merge($module->type, $module->settings),
|
||||||
'qr_label' => 'Website',
|
|
||||||
], $module->settings ?? []),
|
|
||||||
'videoPlaylist' => $videos,
|
'videoPlaylist' => $videos,
|
||||||
'footerContent' => $footerContent,
|
'footerContent' => $footerContent,
|
||||||
];
|
];
|
||||||
|
|
@ -133,20 +132,7 @@ class DisplayPlaylistConfigBuilder
|
||||||
return [
|
return [
|
||||||
'type' => 'b2in',
|
'type' => 'b2in',
|
||||||
'version_name' => $module->name,
|
'version_name' => $module->name,
|
||||||
'settings' => array_replace_recursive([
|
'settings' => DisplayModuleSettings::merge($module->type, $module->settings),
|
||||||
'theme' => 'dark',
|
|
||||||
'header_logo_url' => '../assets/b2in-logo-positive.svg',
|
|
||||||
'header_claim' => 'Connecting Design & Property',
|
|
||||||
'footer_url' => 'B2in.eu',
|
|
||||||
'footer_name' => '',
|
|
||||||
'footer_prefix' => 'by',
|
|
||||||
'qr_url' => '',
|
|
||||||
'transition' => [
|
|
||||||
'type' => 'crossfade',
|
|
||||||
'duration_ms' => 800,
|
|
||||||
],
|
|
||||||
'default_image_duration' => 10,
|
|
||||||
], $module->settings ?? []),
|
|
||||||
'items' => $mediaItems,
|
'items' => $mediaItems,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
@ -157,42 +143,45 @@ class DisplayPlaylistConfigBuilder
|
||||||
*/
|
*/
|
||||||
private function offersData(DisplayVersion $module, Collection $items): array
|
private function offersData(DisplayVersion $module, Collection $items): array
|
||||||
{
|
{
|
||||||
$slides = $items->where('item_type', 'slide')->values()->map(fn ($item) => [
|
$slides = $items->where('item_type', 'slide')->values()->map(function ($item) {
|
||||||
'type' => $item->content['type'] ?? 'product-hero',
|
$content = $item->content;
|
||||||
'duration' => $item->content['duration'] ?? 8000,
|
|
||||||
'image_url' => $item->content['image_url'] ?? '',
|
return [
|
||||||
'badge_text' => $item->content['badge_text'] ?? '',
|
'type' => $content['type'] ?? 'detail',
|
||||||
'eyebrow' => $item->content['eyebrow'] ?? '',
|
'show_logo' => $content['show_logo'] ?? true,
|
||||||
'title' => $item->content['title'] ?? '',
|
'logo_url' => $content['logo_url'] ?? '',
|
||||||
'subline' => $item->content['subline'] ?? '',
|
'brand_text' => $content['brand_text'] ?? '',
|
||||||
'price' => $item->content['price'] ?? '',
|
'duration' => $content['duration'] ?? 8000,
|
||||||
'original_price' => $item->content['original_price'] ?? '',
|
'image_url' => $content['image_url'] ?? '',
|
||||||
'tag_text' => $item->content['tag_text'] ?? '',
|
'badge_text' => $content['badge_text'] ?? '',
|
||||||
'bullets' => $item->content['bullets'] ?? [],
|
'show_badge' => $content['show_badge'] ?? ($content['badge_text'] ?? '') !== '',
|
||||||
'disclaimer' => $item->content['disclaimer'] ?? '',
|
'eyebrow' => $content['eyebrow'] ?? '',
|
||||||
'qr_url' => $item->content['qr_url'] ?? '',
|
'show_eyebrow' => $content['show_eyebrow'] ?? ($content['eyebrow'] ?? '') !== '',
|
||||||
'qr_title' => $item->content['qr_title'] ?? '',
|
'title' => $content['title'] ?? '',
|
||||||
'contact' => $item->content['contact'] ?? '',
|
'subline' => $content['subline'] ?? '',
|
||||||
'show_brand_text' => $item->content['show_brand_text'] ?? false,
|
'show_subline' => $content['show_subline'] ?? ($content['subline'] ?? '') !== '',
|
||||||
'brand_tagline' => $item->content['brand_tagline'] ?? '',
|
'price' => $content['price'] ?? '',
|
||||||
]);
|
'original_price' => $content['original_price'] ?? '',
|
||||||
|
'strike_original_price' => $content['strike_original_price'] ?? false,
|
||||||
|
'tag_text' => $content['tag_text'] ?? '',
|
||||||
|
'show_price' => $content['show_price'] ?? ($content['price'] ?? '') !== '',
|
||||||
|
'bullets' => $content['bullets'] ?? [],
|
||||||
|
'show_bullets' => $content['show_bullets'] ?? ! empty($content['bullets']),
|
||||||
|
'disclaimer' => $content['disclaimer'] ?? '',
|
||||||
|
'show_disclaimer' => $content['show_disclaimer'] ?? ($content['disclaimer'] ?? '') !== '',
|
||||||
|
'qr_url' => $content['qr_url'] ?? '',
|
||||||
|
'qr_title' => $content['qr_title'] ?? '',
|
||||||
|
'show_qr' => $content['show_qr'] ?? ($content['qr_url'] ?? '') !== '',
|
||||||
|
'contact' => $content['contact'] ?? '',
|
||||||
|
'show_contact' => $content['show_contact'] ?? ($content['contact'] ?? '') !== '',
|
||||||
|
'brand_tagline' => $content['brand_tagline'] ?? '',
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'type' => 'offers',
|
'type' => 'offers',
|
||||||
'version_name' => $module->name,
|
'version_name' => $module->name,
|
||||||
'settings' => array_replace_recursive([
|
'settings' => DisplayModuleSettings::merge($module->type, $module->settings),
|
||||||
'loop' => true,
|
|
||||||
'logo_url' => '../logo-cabinet-300.png',
|
|
||||||
'brand_text' => 'Bielefeld',
|
|
||||||
'footer_claim' => '',
|
|
||||||
'footer_url' => '',
|
|
||||||
'qr_default_title' => 'Kontakt',
|
|
||||||
'qr_subtitle' => 'QR scannen',
|
|
||||||
'transition' => [
|
|
||||||
'type' => 'fade',
|
|
||||||
'duration' => 600,
|
|
||||||
],
|
|
||||||
], $module->settings ?? []),
|
|
||||||
'slides' => $slides,
|
'slides' => $slides,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
60
app/Support/DisplayModuleSettings.php
Normal file
60
app/Support/DisplayModuleSettings.php
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Support;
|
||||||
|
|
||||||
|
use App\Enums\DisplayVersionType;
|
||||||
|
|
||||||
|
class DisplayModuleSettings
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public static function defaults(DisplayVersionType|string $type): array
|
||||||
|
{
|
||||||
|
$typeValue = $type instanceof DisplayVersionType ? $type->value : $type;
|
||||||
|
|
||||||
|
return match ($typeValue) {
|
||||||
|
DisplayVersionType::VideoDisplay->value => [
|
||||||
|
'qr_label' => 'Website',
|
||||||
|
],
|
||||||
|
DisplayVersionType::B2in->value => [
|
||||||
|
'theme' => 'dark',
|
||||||
|
'header_logo_url' => '../assets/b2in-logo-positive.svg',
|
||||||
|
'header_claim' => 'Connecting Design & Property',
|
||||||
|
'logo_position' => 'top-left',
|
||||||
|
'claim_position' => 'top-right',
|
||||||
|
'show_logo' => true,
|
||||||
|
'show_claim' => true,
|
||||||
|
'show_footer' => true,
|
||||||
|
'footer_url' => 'B2in.eu',
|
||||||
|
'footer_name' => '',
|
||||||
|
'footer_prefix' => 'by',
|
||||||
|
'qr_url' => '',
|
||||||
|
'transition' => [
|
||||||
|
'type' => 'crossfade',
|
||||||
|
'duration_ms' => 800,
|
||||||
|
],
|
||||||
|
'default_image_duration' => 10,
|
||||||
|
'rotation_weights' => ['immobilien' => 70, 'moebel' => 30],
|
||||||
|
'display_active' => true,
|
||||||
|
],
|
||||||
|
DisplayVersionType::Offers->value => [
|
||||||
|
'loop' => true,
|
||||||
|
'transition' => [
|
||||||
|
'type' => 'fade',
|
||||||
|
'duration' => 600,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
default => [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed>|null $settings
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public static function merge(DisplayVersionType|string $type, ?array $settings): array
|
||||||
|
{
|
||||||
|
return array_replace_recursive(self::defaults($type), $settings ?? []);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -22,4 +22,7 @@ return [
|
||||||
// Haupt-Domain
|
// Haupt-Domain
|
||||||
'domain' => env('DISPLAY_DOMAIN', 'b2in.eu'),
|
'domain' => env('DISPLAY_DOMAIN', 'b2in.eu'),
|
||||||
|
|
||||||
|
// Öffentliche Player-URL der Display-Domain
|
||||||
|
'player_url' => env('DISPLAY_PLAYER_URL', 'https://cabinet.b2in.eu/display'),
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('display_display_version');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::create('display_display_version', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('display_id')->constrained()->cascadeOnDelete();
|
||||||
|
$table->foreignId('display_version_id')->constrained()->cascadeOnDelete();
|
||||||
|
$table->integer('sort_order')->default(0);
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->unique(['display_id', 'display_version_id']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -21,5 +21,7 @@ class DatabaseSeeder extends Seeder
|
||||||
'email' => 'kevin.adametz@me.com',
|
'email' => 'kevin.adametz@me.com',
|
||||||
'password' => Hash::make('xunfew-0Jygjy-minnyt'),
|
'password' => Hash::make('xunfew-0Jygjy-minnyt'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$this->call(TestDisplaySeeder::class);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -169,7 +169,9 @@ class DisplayVersionSeeder extends Seeder
|
||||||
'qr_url' => 'https://cabinet-bielefeld.de',
|
'qr_url' => 'https://cabinet-bielefeld.de',
|
||||||
'qr_title' => 'Kontakt',
|
'qr_title' => 'Kontakt',
|
||||||
'contact' => "0521 98620100\nTel. oder WhatsApp",
|
'contact' => "0521 98620100\nTel. oder WhatsApp",
|
||||||
'show_brand_text' => true,
|
'show_logo' => true,
|
||||||
|
'logo_url' => '../logo-cabinet-300.png',
|
||||||
|
'brand_text' => 'Bielefeld',
|
||||||
'brand_tagline' => "Planung • Beratung\nLieferung & Montage",
|
'brand_tagline' => "Planung • Beratung\nLieferung & Montage",
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
|
|
@ -188,7 +190,9 @@ class DisplayVersionSeeder extends Seeder
|
||||||
'qr_url' => 'https://cabinet-bielefeld.de',
|
'qr_url' => 'https://cabinet-bielefeld.de',
|
||||||
'qr_title' => 'Reservieren',
|
'qr_title' => 'Reservieren',
|
||||||
'contact' => "0521 98620100\nTel. oder WhatsApp",
|
'contact' => "0521 98620100\nTel. oder WhatsApp",
|
||||||
'show_brand_text' => false,
|
'show_logo' => true,
|
||||||
|
'logo_url' => '../logo-cabinet-300.png',
|
||||||
|
'brand_text' => '',
|
||||||
'brand_tagline' => '',
|
'brand_tagline' => '',
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
|
|
@ -212,7 +216,9 @@ class DisplayVersionSeeder extends Seeder
|
||||||
'qr_url' => 'https://cabinet-bielefeld.de',
|
'qr_url' => 'https://cabinet-bielefeld.de',
|
||||||
'qr_title' => 'Reservieren',
|
'qr_title' => 'Reservieren',
|
||||||
'contact' => "0521 98620100\nTel. oder WhatsApp",
|
'contact' => "0521 98620100\nTel. oder WhatsApp",
|
||||||
'show_brand_text' => false,
|
'show_logo' => true,
|
||||||
|
'logo_url' => '../logo-cabinet-300.png',
|
||||||
|
'brand_text' => '',
|
||||||
'brand_tagline' => '',
|
'brand_tagline' => '',
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
|
|
@ -231,7 +237,9 @@ class DisplayVersionSeeder extends Seeder
|
||||||
'qr_url' => 'https://cabinet-bielefeld.de',
|
'qr_url' => 'https://cabinet-bielefeld.de',
|
||||||
'qr_title' => 'Sichern',
|
'qr_title' => 'Sichern',
|
||||||
'contact' => "0521 98620100\nTel. oder WhatsApp",
|
'contact' => "0521 98620100\nTel. oder WhatsApp",
|
||||||
'show_brand_text' => false,
|
'show_logo' => true,
|
||||||
|
'logo_url' => '../logo-cabinet-300.png',
|
||||||
|
'brand_text' => '',
|
||||||
'brand_tagline' => '',
|
'brand_tagline' => '',
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
|
||||||
31
database/seeders/TestDisplaySeeder.php
Normal file
31
database/seeders/TestDisplaySeeder.php
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Seeders;
|
||||||
|
|
||||||
|
use App\Models\Display;
|
||||||
|
use Illuminate\Database\Seeder;
|
||||||
|
|
||||||
|
class TestDisplaySeeder extends Seeder
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Stellt sicher, dass genau ein Test-Display existiert (Konzept §10.1).
|
||||||
|
* Idempotent: legt nur an, wenn noch kein Test-Display vorhanden ist.
|
||||||
|
*/
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
if (Display::query()->where('is_test', true)->exists()) {
|
||||||
|
$this->command?->info('Test-Display existiert bereits – übersprungen.');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Display::query()->create([
|
||||||
|
'name' => 'Test-Display',
|
||||||
|
'location' => 'Vorschau / Test',
|
||||||
|
'is_active' => true,
|
||||||
|
'is_test' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->command?->info('Test-Display angelegt.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -16,8 +16,8 @@ Im Admin-Portal unter `portal.b2in.test/admin/cms/` existiert der Bereich **Stor
|
||||||
|---|---|
|
|---|---|
|
||||||
| `cms/display-dashboard` | Übersicht / Einstieg |
|
| `cms/display-dashboard` | Übersicht / Einstieg |
|
||||||
| `cms/display-media` | Mediathek (eigene Display-Mediathek, getrennt von Flux CMS) |
|
| `cms/display-media` | Mediathek (eigene Display-Mediathek, getrennt von Flux CMS) |
|
||||||
| `cms/display-versions` | Inhalts-„Versionen" |
|
| `cms/display-modules` | Inhalts-Module |
|
||||||
| `cms/display-versions/{id}/edit` | Editor für eine Version |
|
| `cms/display-modules/{id}/edit` | Editor für ein Modul |
|
||||||
| `cms/displays` | Physische Displays + Playlist-Zuweisung |
|
| `cms/displays` | Physische Displays + Playlist-Zuweisung |
|
||||||
| `cms/cabinet-tablet` | Info-Tablet (Öffnungszeiten/Status) |
|
| `cms/cabinet-tablet` | Info-Tablet (Öffnungszeiten/Status) |
|
||||||
|
|
||||||
|
|
@ -25,13 +25,14 @@ Im Admin-Portal unter `portal.b2in.test/admin/cms/` existiert der Bereich **Stor
|
||||||
|
|
||||||
```
|
```
|
||||||
displays (5 Datensätze live)
|
displays (5 Datensätze live)
|
||||||
└── m:n via display_display_version (sort_order = Playlist-Reihenfolge)
|
└── 1:n display_playlists (Live/Entwurf)
|
||||||
└── display_versions (5 Datensätze live)
|
└── 1:n display_playlist_items (sort_order = Playlist-Reihenfolge)
|
||||||
├── type: video-display | b2in | offers
|
└── display_versions (technisch), fachlich Module
|
||||||
├── settings: JSON
|
├── type: video-display | b2in | offers
|
||||||
└── 1:n display_version_items (17 Datensätze live)
|
├── settings: JSON
|
||||||
├── item_type: video | footer | media | slide
|
└── 1:n display_version_items
|
||||||
└── content: JSON
|
├── item_type: video | footer | media | slide
|
||||||
|
└── content: JSON
|
||||||
```
|
```
|
||||||
|
|
||||||
### 1.3 Echte Live-Daten (Stand heute)
|
### 1.3 Echte Live-Daten (Stand heute)
|
||||||
|
|
@ -83,7 +84,7 @@ displays (5 Datensätze live)
|
||||||
| Mediathek | **Display-Mediathek** *(unverändert)* | Bilder/Videos für Displays. |
|
| Mediathek | **Display-Mediathek** *(unverändert)* | Bilder/Videos für Displays. |
|
||||||
| Info-Tablet | **Info-Tablet** *(unverändert)* | Eingangs-Tablet mit Öffnungszeiten. |
|
| Info-Tablet | **Info-Tablet** *(unverändert)* | Eingangs-Tablet mit Öffnungszeiten. |
|
||||||
|
|
||||||
Routen werden entsprechend umbenannt: `display-versions` → `display-modules`.
|
Routen wurden entsprechend umbenannt: `display-versions` → `display-modules`. Die Übergangs-Redirects wurden in Phase 7 entfernt.
|
||||||
|
|
||||||
### 2.2 Neues mentales Modell
|
### 2.2 Neues mentales Modell
|
||||||
|
|
||||||
|
|
@ -181,7 +182,7 @@ für jedes Display D:
|
||||||
erstelle display_playlists (display_id=D.id, status='published', published_at=now())
|
erstelle display_playlists (display_id=D.id, status='published', published_at=now())
|
||||||
für jeden Eintrag aus display_display_version (display_id=D.id), sortiert nach sort_order:
|
für jeden Eintrag aus display_display_version (display_id=D.id), sortiert nach sort_order:
|
||||||
erstelle display_playlist_items (...)
|
erstelle display_playlist_items (...)
|
||||||
display_display_version-Tabelle bleibt vorerst → wird in Phase 7 dropped.
|
display_display_version-Tabelle wurde in Phase 7 dropped.
|
||||||
```
|
```
|
||||||
|
|
||||||
**Ergebnis nach Migration:** Alle 5 Displays haben eine Live-Bespielung, kein Entwurf. Konsumenten-API liefert exakt das gleiche wie heute.
|
**Ergebnis nach Migration:** Alle 5 Displays haben eine Live-Bespielung, kein Entwurf. Konsumenten-API liefert exakt das gleiche wie heute.
|
||||||
|
|
@ -355,15 +356,15 @@ Jede Phase liefert ein in sich getestetes, deploybares Inkrement.
|
||||||
- [ ] Player-Templates: Single-Module-Modus
|
- [ ] Player-Templates: Single-Module-Modus
|
||||||
|
|
||||||
### Phase 6 – Umbenennung & Onboarding (Tag 3)
|
### Phase 6 – Umbenennung & Onboarding (Tag 3)
|
||||||
- [ ] Routen: `display-versions` → `display-modules` (mit 301-Redirect)
|
- [x] Routen: `display-versions` → `display-modules`
|
||||||
- [ ] Komponenten / Views umbenennen
|
- [x] Komponenten / Views umbenennen
|
||||||
- [ ] Dashboard-Texte / Hilfe-Bausteine aktualisieren
|
- [x] Dashboard-Texte / Hilfe-Bausteine aktualisieren
|
||||||
- [ ] Tooltips an Schlüsselstellen
|
- [x] Tooltips an Schlüsselstellen
|
||||||
|
|
||||||
### Phase 7 – Aufräumen (Tag 4)
|
### Phase 7 – Aufräumen (Tag 4)
|
||||||
- [ ] `display_display_version`-Tabelle dropped
|
- [x] `display_display_version`-Tabelle dropped
|
||||||
- [ ] Alte Routen entfernt
|
- [x] Alte Routen entfernt
|
||||||
- [ ] `DISPLAY_CMS_README.md` aktualisiert (in `dev/` ablegen)
|
- [x] Entwicklerdoku in `dev/displays-11-05-2026` aktualisiert
|
||||||
- [ ] Vollständiger Test-Run
|
- [ ] Vollständiger Test-Run
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,8 @@
|
||||||
| **4** | Admin-UI: Entwurf-Editor (Iframe-Vorschau) | ✅ 12.05.2026 |
|
| **4** | Admin-UI: Entwurf-Editor (Iframe-Vorschau) | ✅ 12.05.2026 |
|
||||||
| **5** | Modul-Editor: 3-stufige Vorschau | ✅ 12.05.2026 |
|
| **5** | Modul-Editor: 3-stufige Vorschau | ✅ 12.05.2026 |
|
||||||
| **6** | Umbenennung Versionen → Module + Onboarding | ✅ 12.05.2026 |
|
| **6** | Umbenennung Versionen → Module + Onboarding | ✅ 12.05.2026 |
|
||||||
| **7** | Aufräumen + alte Pivot-Tabelle entfernen | ⏳ offen |
|
| **7** | Aufräumen + alte Pivot-Tabelle entfernen | ✅ 13.05.2026 |
|
||||||
|
| **8** | Review: Fehler / Optimierungen / Erweiterungen | 🟡 29.05.2026 (Befundaufnahme) |
|
||||||
|
|
||||||
Legende: ✅ fertig · 🟡 in Arbeit · ⏳ offen · ⛔ blockiert
|
Legende: ✅ fertig · 🟡 in Arbeit · ⏳ offen · ⛔ blockiert
|
||||||
|
|
||||||
|
|
@ -245,7 +246,7 @@ Umsetzung:
|
||||||
|
|
||||||
## Phase 6 – Umbenennung Versionen → Module + Onboarding
|
## Phase 6 – Umbenennung Versionen → Module + Onboarding
|
||||||
|
|
||||||
**Ziel:** Die Admin-UI verwendet den fachlich korrekten Begriff „Module“. Alte URLs bleiben kompatibel und leiten weiter.
|
**Ziel:** Die Admin-UI verwendet den fachlich korrekten Begriff „Module“. Alte URLs wurden während der Übergangsphase per 301 weitergeleitet und in Phase 7 entfernt.
|
||||||
|
|
||||||
### Stand 12.05.2026 – ✅ abgeschlossen
|
### Stand 12.05.2026 – ✅ abgeschlossen
|
||||||
|
|
||||||
|
|
@ -261,9 +262,9 @@ Dateien:
|
||||||
Umsetzung:
|
Umsetzung:
|
||||||
- Neue Routen: `admin/cms/display-modules` und `admin/cms/display-modules/{displayVersion}/edit`
|
- Neue Routen: `admin/cms/display-modules` und `admin/cms/display-modules/{displayVersion}/edit`
|
||||||
- Neue Routennamen: `admin.cms.display-modules` und `admin.cms.display-module-edit`
|
- Neue Routennamen: `admin.cms.display-modules` und `admin.cms.display-module-edit`
|
||||||
- Alte `display-versions`-Routen bleiben erhalten und leiten per 301 auf die Modul-Routen weiter
|
- Alte `display-versions`-Routen waren während der Übergangsphase als 301-Redirects aktiv und wurden in Phase 7 entfernt
|
||||||
- Sidebar, Dashboard, Listen- und Editor-Texte verwenden „Module“
|
- Sidebar, Dashboard, Listen- und Editor-Texte verwenden „Module“
|
||||||
- Technische Modell-/Klassennamen bleiben bis Phase 7 kompatibel bei `DisplayVersion`
|
- Technische Modell-/Klassennamen bleiben bei `DisplayVersion`, da sie fachlich weiterhin die wiederverwendbaren Module abbilden
|
||||||
|
|
||||||
#### Tests
|
#### Tests
|
||||||
|
|
||||||
|
|
@ -276,3 +277,99 @@ tests/Feature/DisplayPlaylistMigrationTest.php – ok
|
||||||
|
|
||||||
Insgesamt 64 grüne Tests für Phasen 5/6 und die angrenzenden Display-Flows. Pint clean.
|
Insgesamt 64 grüne Tests für Phasen 5/6 und die angrenzenden Display-Flows. Pint clean.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 7 – Technisches Aufräumen & Optimierung
|
||||||
|
|
||||||
|
**Ziel:** Nach Stabilisierung des neuen Playlist-Flows wird die alte Pivot-Kompatibilität entfernt und der Modul-Editor weiter vereinheitlicht.
|
||||||
|
|
||||||
|
### Stand 13.05.2026 – ✅ umgesetzt
|
||||||
|
|
||||||
|
Dateien:
|
||||||
|
- `app/Models/Display.php`
|
||||||
|
- `app/Models/DisplayVersion.php`
|
||||||
|
- `app/Livewire/Admin/Cms/DisplayList.php`
|
||||||
|
- `app/Console/Commands/MigrateLegacyDisplays.php`
|
||||||
|
- `app/Support/DisplayModuleSettings.php`
|
||||||
|
- `app/Services/DisplayPlaylistConfigBuilder.php`
|
||||||
|
- `app/Livewire/Admin/Cms/DisplayVersionEditor.php`
|
||||||
|
- `app/Livewire/Admin/Cms/DisplayVersionList.php`
|
||||||
|
- `routes/admin.php`
|
||||||
|
- `database/migrations/2026_05_13_103600_drop_display_display_version_table.php`
|
||||||
|
- `resources/views/livewire/admin/cms/display-list.blade.php`
|
||||||
|
- `resources/views/livewire/admin/cms/display-version-editor.blade.php`
|
||||||
|
- `resources/views/livewire/admin/cms/partials/version-editor-video.blade.php`
|
||||||
|
|
||||||
|
Umsetzung:
|
||||||
|
- Alte Pivot-Tabelle `display_display_version` wird per Migration entfernt
|
||||||
|
- Legacy-Relationen `Display::versions()` und `DisplayVersion::displays()` wurden entfernt
|
||||||
|
- Display-Bearbeitung, Draft-Veröffentlichung und Legacy-Migrations-Command schreiben ausschließlich in `display_playlists` und `display_playlist_items`
|
||||||
|
- Alte `display-versions`-Redirect-Routen wurden entfernt; die Admin-UI nutzt nur noch `display-modules`
|
||||||
|
- Modul-Settings-Defaults liegen zentral in `App\Support\DisplayModuleSettings` und werden von Editor, Listen-Erstellung und API-Config-Builder gemeinsam genutzt
|
||||||
|
- Admin-Iframes laden per `loading="lazy"` verzögert, um die parallelen Player-Vorschauen leichter zu halten
|
||||||
|
- Video-Display-Items zeigen im Editor sichtbar an, ob die Quelle aus der Mediathek oder aus einem Legacy-Dateinamen kommt
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 8 – Review 29.05.2026 (Befundaufnahme)
|
||||||
|
|
||||||
|
Geprüft wurden die drei Navigationspunkte:
|
||||||
|
- `admin/cms/display-media` → Volt `admin.cms.display-media-library`
|
||||||
|
- `admin/cms/display-modules` → `App\Livewire\Admin\Cms\DisplayVersionList` (+ Editor `DisplayVersionEditor`)
|
||||||
|
- `admin/cms/displays` → `App\Livewire\Admin\Cms\DisplayList`
|
||||||
|
|
||||||
|
**Vorgehen:** Code-Review der Komponenten/Views/Services/Player + Laravel-Logs + DB-Stand. Eine Browser-Sichtprüfung war in dieser Session nicht möglich (Browser-MCP nicht verfügbar). **Alle Befunde (#1–#11) wurden inzwischen umgesetzt** (Details je Punkt unten).
|
||||||
|
|
||||||
|
**Aktueller Datenstand (DB):** 5 Displays (davon **0** Test-Displays), 6 Module, 7 Playlists (5 Live + 2 Entwürfe), 13 Medien.
|
||||||
|
|
||||||
|
**Tests:** `DisplayListTest`, `DisplayMediaTest`, `DisplayVersionTest`, `DisplayVersionApiTest`, `DisplayPlaylistMigrationTest` → **95 passed** (276 Assertions). Keine CMS-Fehler in `storage/logs` (vorhandene Log-Fehler betreffen unrelated `immobilien-azizi`-Routen).
|
||||||
|
|
||||||
|
### 🔴 Fehler (sollten behoben werden)
|
||||||
|
|
||||||
|
1. **Mediathek-Suche hebelt alle Filter aus.** ✅ **behoben 29.05.2026**
|
||||||
|
In `display-media-library.blade.php` (`$media`-Computed) und in `DisplayMediaPicker::resolveMediaItems()` wurde die Suche als `->where('filename','like',…)->orWhere('title','like',…)` ohne Gruppierung an die vorherigen `when()`-Filter gehängt. Durch SQL-Präzedenz (`AND` bindet stärker als `OR`) wurde die Query zu `… AND filename LIKE … OR title LIKE …`. Sobald gesucht wurde, wurden **Typ-, Quelle-, Sammlungs- und (beim Picker) der `active()`-Filter ignoriert**, sobald ein Treffer über `title` zustande kam.
|
||||||
|
*Behebung:* Neuer gekapselter Scope `DisplayMedia::scopeSearch()` (Closure um `filename`/`title`), den Library und Picker gemeinsam nutzen. Tests: `keeps preceding filters when combined with the search scope`, `search scope respects the active filter on the media picker` in `DisplayMediaTest.php`.
|
||||||
|
|
||||||
|
2. **Inhalt-Bearbeiten reaktiviert deaktivierte Items.** ✅ **behoben 29.05.2026**
|
||||||
|
In `DisplayVersionEditor` übergab `loadItemContent()` nur das `content`-Array an `loadVideoContent()/loadFooterContent()/loadMediaContent()/loadSlideContent()`. Alle vier setzten `…IsActive = true` fest – das tatsächliche `is_active` des Items wurde nie geladen. Wer ein zuvor per Auge-Icon deaktiviertes Item öffnete und „Aktualisieren" klickte, hat es über `getActiveFlag()` **ungewollt wieder aktiviert**.
|
||||||
|
*Behebung:* `loadItemContent()` reicht `(bool) $item->is_active` an die Loader durch, die es in die jeweilige `…IsActive`-Property schreiben. Test: `editing an inactive item keeps it inactive` in `DisplayVersionTest.php`.
|
||||||
|
|
||||||
|
### 🟡 Optimierungen / Aufräumen
|
||||||
|
|
||||||
|
3. **Toter Code im Video-Editor.** ✅ **behoben 29.05.2026** `DisplayVersionEditor::loadAvailableVideos()`, Property `$availableVideos`, der `mount()`-Aufruf und der ungenutzte `Illuminate\Support\Facades\File`-Import wurden entfernt (im aktiven Editor-Blade ungenutzt; das einzige Blade mit `availableVideos` gehört zur Legacy-`CabinetDisplay`). Spart den Dateisystem-Scan von `public/_cabinet/assets` bei jedem Editor-Aufruf.
|
||||||
|
|
||||||
|
4. **Modul-Löschen ohne Schutz.** ✅ **behoben 29.05.2026** `DisplayVersionList::deleteVersion()` ermittelt jetzt den `displays_count` (distinct Displays über Playlists) und **blockiert** das Löschen, solange das Modul in irgendeiner Bespielung (Live oder Entwurf) genutzt wird; es erscheint ein roter Flash-Hinweis mit Anzahl. Module ohne Nutzung lassen sich weiterhin löschen. Test: `cannot delete a display version that is used by a playlist` in `DisplayVersionTest.php`.
|
||||||
|
|
||||||
|
5. **Doppelte Such-Logik** zwischen Library und Picker (siehe #1) → ein gemeinsamer Query-Scope `DisplayMedia::scopeSearch(string $term)` verhindert künftige Divergenz. ✅ **erledigt 29.05.2026** (im Zuge von #1).
|
||||||
|
|
||||||
|
6. **Preview-Token bleibt nach Veröffentlichen bestehen.** ✅ **behoben 29.05.2026**
|
||||||
|
`DisplayList::publishDraft()`/`discardDraft()` ließen `displays.preview_token` gesetzt, obwohl `/preview/{token}` danach 404 lieferte (kein Draft mehr); ein Rotations-Button fehlte ganz.
|
||||||
|
*Behebung:* Neue Model-Methoden `Display::rotatePreviewToken()` und `Display::clearPreviewToken()`. `publishDraft()` und `discardDraft()` setzen den Token jetzt zurück (alter Link wird ungültig). Im Entwurf gibt es einen „Link erneuern"-Button (`rotatePreviewToken()` mit `wire:confirm`), der bei offenem Entwurf zugleich die Iframe-Vorschau aktualisiert. Tests: `publishing a draft clears the preview token`, `can discard a draft playlist` (erweitert) und `can rotate the preview token of a draft` in `DisplayListTest.php`.
|
||||||
|
|
||||||
|
### 🟠 Konfiguration / Dev-Umgebung
|
||||||
|
|
||||||
|
7. **Live-Vorschau zeigt im lokalen Dev auf die Produktion.** ✅ **behoben 29.05.2026**
|
||||||
|
`config/display.php` `player_url` defaultete auf `https://cabinet.b2in.eu/display`, und `DISPLAY_PLAYER_URL` war in `.env` nicht gesetzt. In `display-list.blade.php` nutzten „Vorschau", „Live-URL zum Kopieren" und „Display-Übersicht öffnen" diese Produktions-URL, während die Entwurfs-„Test-URL" über `url('/preview/…')` lokal lief. Lokal sah man Live also den Produktionsstand, Entwurf aber lokale Daten – inkonsistent und irreführend.
|
||||||
|
*Behebung:* Player-Seite (`public/_cabinet/display/`) **und** die Display-API liegen auf der Portal-Domain, daher ist der Player lokal unter `https://portal.b2in.test/_cabinet/display` erreichbar (der Player ermittelt `BASE_URL` aus `window.location.origin` und ruft die API auf derselben Domain auf).
|
||||||
|
- `.env`: `DISPLAY_PLAYER_URL=https://portal.b2in.test/_cabinet/display` gesetzt (Config-Cache geleert).
|
||||||
|
- `.env.example`: dokumentiert (Live-Wert als Kommentar).
|
||||||
|
- `phpunit.xml`: `DISPLAY_PLAYER_URL=https://cabinet.b2in.eu/display` gepinnt, damit die bestehenden URL-Tests (`DisplayListTest`, `DisplayVersionApiTest`) deterministisch gegen den Produktionswert prüfen, unabhängig von der lokalen `.env`.
|
||||||
|
- Der Produktions-Fallback in `config/display.php` (und damit das Live-Verhalten) bleibt unverändert.
|
||||||
|
|
||||||
|
### 🟢 Erweiterungen / Verbesserungen (UX)
|
||||||
|
|
||||||
|
8. **Test-Display existiert nicht.** ✅ **behoben 29.05.2026** Neuer idempotenter `Database\Seeders\TestDisplaySeeder` legt genau **ein** Test-Display an (überspringt, falls schon vorhanden) und ist im `DatabaseSeeder` registriert. In der Dev-DB wurde das Test-Display angelegt. Tests: `seeds exactly one test display`, `is idempotent and does not create a second test display` in `TestDisplaySeederTest.php`.
|
||||||
|
|
||||||
|
9. **Externe Bilder ohne Thumbnail.** ✅ **behoben 29.05.2026** Grid, Listenansicht und Detail-Sidebar (`display-media-library.blade.php`) rendern externe Bilder jetzt direkt via `<img src="{{ external_url }}">` (gemeinsame `$thumbSrc`-Logik: Upload-Thumbnail → sonst `external_url`). Nicht-Bild-Externe behalten das Link-Icon. Test: `renders external image media with an inline thumbnail` in `DisplayMediaTest.php`.
|
||||||
|
|
||||||
|
10. **Mediathek-Pagination ohne `resetPage`.** ✅ **behoben 29.05.2026** Die Volt-Komponente nutzt jetzt `WithPagination` und Update-Hooks (`updatedSearch/updatedFilterType/updatedFilterSource/updatedFilterCollection`), die bei jeder Filter-/Suchänderung `resetPage()` aufrufen. Test: `resets pagination to page one when a filter changes` in `DisplayMediaTest.php`.
|
||||||
|
|
||||||
|
11. **Modul-Hinzufügen nur einzeln.** ✅ **behoben 29.05.2026** Der Bespielungs-Dialog (`display-list.blade.php`) nutzt jetzt einen durchsuchbaren Flux-Listbox-Multi-Select (`versionsToAdd`) plus „Hinzufügen"-Button (`addSelectedVersions()`), der alle ausgewählten Module auf einmal übernimmt (Duplikate werden übersprungen). Die programmatische `addVersion()`-Methode bleibt für Einzel-Adds/Tests erhalten. Test: `can add multiple modules to a playlist at once` in `DisplayListTest.php`.
|
||||||
|
|
||||||
|
### Empfohlene Reihenfolge
|
||||||
|
|
||||||
|
1. #1 + #2 (echte Fehler, klein, mit Tests) → 2. #7 (Dev-Konfiguration, blockiert lokales Testen) → 3. #3/#4/#5/#6 (Aufräumen/Robustheit) → 4. #8–#11 (UX-Erweiterungen). Jeder Punkt wird gemäß Projekt-Regeln mit Test abgesichert und mit Pint formatiert.
|
||||||
|
|
||||||
|
**Stand 29.05.2026:** Alle Befunde (#1–#11) sind umgesetzt, getestet und mit Pint formatiert.
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ services:
|
||||||
REDIS_HOST: global-redis
|
REDIS_HOST: global-redis
|
||||||
volumes:
|
volumes:
|
||||||
- '.:/var/www/html'
|
- '.:/var/www/html'
|
||||||
- './.devcontainer/php-upload-limits.ini:/etc/php/8.4/cli/conf.d/99-upload-limits.ini:ro'
|
- './.devcontainer/php-upload-limits.ini:/etc/php/8.4/cli/conf.d/100-b2in-upload-limits.ini:ro'
|
||||||
networks:
|
networks:
|
||||||
- sail
|
- sail
|
||||||
- proxy
|
- proxy
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
accept="image/jpeg,image/png,image/gif,image/webp,image/svg+xml,.pdf,.doc,.docx,.jpg,.jpeg,.png">
|
accept="image/jpeg,image/png,image/gif,image/webp,image/svg+xml,.pdf,.doc,.docx,.jpg,.jpeg,.png">
|
||||||
<flux:file-upload.dropzone
|
<flux:file-upload.dropzone
|
||||||
heading="Dateien hochladen"
|
heading="Dateien hochladen"
|
||||||
text="Bilder (JPG, PNG, WebP, SVG) und Dokumente (PDF, DOC) — max. 10 MB pro Datei"
|
text="Bilder (JPG, PNG, WebP, SVG) und Dokumente (PDF, DOC) — max. 200 MB pro Datei"
|
||||||
with-progress />
|
with-progress />
|
||||||
</flux:file-upload>
|
</flux:file-upload>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ class MediaLibraryUploader extends Component
|
||||||
{
|
{
|
||||||
$this->validate([
|
$this->validate([
|
||||||
'uploads' => 'nullable|array|max:20',
|
'uploads' => 'nullable|array|max:20',
|
||||||
'uploads.*' => 'file|mimes:jpeg,jpg,png,gif,webp,svg,pdf,doc,docx|max:10240',
|
'uploads.*' => 'file|mimes:jpeg,jpg,png,gif,webp,svg,pdf,doc,docx|max:204800',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$service = app(MediaConversionService::class);
|
$service = app(MediaConversionService::class);
|
||||||
|
|
|
||||||
|
|
@ -73,7 +73,7 @@ class MediaPicker extends Component
|
||||||
{
|
{
|
||||||
$this->validate([
|
$this->validate([
|
||||||
'quickUploads' => 'nullable|array|max:5',
|
'quickUploads' => 'nullable|array|max:5',
|
||||||
'quickUploads.*' => 'file|mimes:jpeg,jpg,png,gif,webp,svg,pdf,doc,docx|max:10240',
|
'quickUploads.*' => 'file|mimes:jpeg,jpg,png,gif,webp,svg,pdf,doc,docx|max:204800',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$service = app(MediaConversionService::class);
|
$service = app(MediaConversionService::class);
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ class MediaUploader extends Component
|
||||||
|
|
||||||
public string $directory = 'cms/uploads';
|
public string $directory = 'cms/uploads';
|
||||||
|
|
||||||
#[Validate('file|max:10240')]
|
#[Validate('file|max:204800')]
|
||||||
public $file;
|
public $file;
|
||||||
|
|
||||||
public function updatedFile(): void
|
public function updatedFile(): void
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@
|
||||||
<php>
|
<php>
|
||||||
<env name="APP_ENV" value="testing"/>
|
<env name="APP_ENV" value="testing"/>
|
||||||
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
|
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
|
||||||
|
<env name="DISPLAY_PLAYER_URL" value="https://cabinet.b2in.eu/display"/>
|
||||||
<env name="BCRYPT_ROUNDS" value="4"/>
|
<env name="BCRYPT_ROUNDS" value="4"/>
|
||||||
<env name="CACHE_STORE" value="array"/>
|
<env name="CACHE_STORE" value="array"/>
|
||||||
<env name="DB_CONNECTION" value="sqlite" force="true"/>
|
<env name="DB_CONNECTION" value="sqlite" force="true"/>
|
||||||
|
|
|
||||||
|
|
@ -85,11 +85,11 @@ Beim Hochladen neuer Videos beachten:
|
||||||
- [ ] Format: **MP4** (H.264 + AAC)
|
- [ ] Format: **MP4** (H.264 + AAC)
|
||||||
- [ ] Auflösung: **Max 1920x1080**
|
- [ ] Auflösung: **Max 1920x1080**
|
||||||
- [ ] Bitrate: **5-10 Mbps**
|
- [ ] Bitrate: **5-10 Mbps**
|
||||||
- [ ] Dateigröße: **Max 100 MB**
|
- [ ] Dateigröße: **Max 200 MB**
|
||||||
- [ ] Länge: **15-60 Sekunden** (optimal)
|
- [ ] Länge: **15-60 Sekunden** (optimal)
|
||||||
|
|
||||||
### ⚠️ Vermeiden:
|
### ⚠️ Vermeiden:
|
||||||
- ❌ Zu große Dateien (>100MB)
|
- ❌ Zu große Dateien (>200MB)
|
||||||
- ❌ Zu hohe Bitrate (>10 Mbps)
|
- ❌ Zu hohe Bitrate (>10 Mbps)
|
||||||
- ❌ Zu lange Videos (>3 Min)
|
- ❌ Zu lange Videos (>3 Min)
|
||||||
- ❌ Exotische Formate (MOV, AVI, WMV)
|
- ❌ Exotische Formate (MOV, AVI, WMV)
|
||||||
|
|
|
||||||
|
|
@ -214,7 +214,7 @@ setTimeout(() => {
|
||||||
|
|
||||||
### 3. **Dateigrößen**
|
### 3. **Dateigrößen**
|
||||||
- **Optimal:** 10-50 MB pro Video
|
- **Optimal:** 10-50 MB pro Video
|
||||||
- **Maximum:** 100 MB pro Video
|
- **Maximum:** 200 MB pro Video
|
||||||
- **Warum:** Schnelleres Laden, weniger Buffering
|
- **Warum:** Schnelleres Laden, weniger Buffering
|
||||||
|
|
||||||
### 4. **Playlist-Größe**
|
### 4. **Playlist-Größe**
|
||||||
|
|
|
||||||
|
|
@ -143,6 +143,24 @@
|
||||||
text-transform: uppercase; color: rgba(255,255,255,0.7);
|
text-transform: uppercase; color: rgba(255,255,255,0.7);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* B2in Brand (positionable logo + claim) */
|
||||||
|
.b2in-brand { position: absolute; z-index: 12; display: flex; align-items: center; max-width: 60%; }
|
||||||
|
.b2in-brand-logo img { height: 3.5vh; display: block; filter: drop-shadow(0 1px 4px rgba(0,0,0,0.45)); }
|
||||||
|
.b2in-brand-claim {
|
||||||
|
font-size: 1.3vh; font-weight: 300; letter-spacing: 0.15em;
|
||||||
|
text-transform: uppercase; color: rgba(255,255,255,0.85);
|
||||||
|
text-shadow: 0 1px 4px rgba(0,0,0,0.55);
|
||||||
|
}
|
||||||
|
.b2in-brand.pos-top-left { top: 2.5vh; left: 3vh; }
|
||||||
|
.b2in-brand.pos-top-right { top: 2.5vh; right: 3vh; }
|
||||||
|
.b2in-brand.pos-bottom-left { bottom: 2.5vh; left: 3vh; }
|
||||||
|
.b2in-brand.pos-bottom-right { bottom: 2.5vh; right: 3vh; }
|
||||||
|
|
||||||
|
/* Legibility scrims behind positioned brand elements */
|
||||||
|
.b2in-scrim { position: absolute; left: 0; right: 0; height: 12vh; z-index: 11; pointer-events: none; }
|
||||||
|
.b2in-scrim-top { top: 0; background: linear-gradient(to bottom, rgba(0,0,0,0.6), transparent); }
|
||||||
|
.b2in-scrim-bottom { bottom: 0; background: linear-gradient(to top, rgba(0,0,0,0.6), transparent); }
|
||||||
|
|
||||||
/* B2in Media */
|
/* B2in Media */
|
||||||
.b2in-media {
|
.b2in-media {
|
||||||
flex: 1; position: relative; overflow: hidden;
|
flex: 1; position: relative; overflow: hidden;
|
||||||
|
|
@ -171,6 +189,8 @@
|
||||||
font-size: 1.8vh; font-weight: 300; color: rgba(255,255,255,0.7);
|
font-size: 1.8vh; font-weight: 300; color: rgba(255,255,255,0.7);
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
/* Without footer the text reclaims the footer's space at the bottom */
|
||||||
|
.b2in-layer.no-footer .b2in-text { padding-bottom: 4vh; }
|
||||||
|
|
||||||
/* B2in Footer */
|
/* B2in Footer */
|
||||||
.b2in-footer {
|
.b2in-footer {
|
||||||
|
|
@ -204,6 +224,9 @@
|
||||||
background: linear-gradient(to bottom, rgba(247,248,250,0.9), transparent);
|
background: linear-gradient(to bottom, rgba(247,248,250,0.9), transparent);
|
||||||
}
|
}
|
||||||
.b2in-layer[data-theme="light"] .b2in-claim { color: rgba(43,63,81,0.6); }
|
.b2in-layer[data-theme="light"] .b2in-claim { color: rgba(43,63,81,0.6); }
|
||||||
|
.b2in-layer[data-theme="light"] .b2in-brand-claim { color: rgba(43,63,81,0.75); text-shadow: 0 1px 3px rgba(255,255,255,0.5); }
|
||||||
|
.b2in-layer[data-theme="light"] .b2in-scrim-top { background: linear-gradient(to bottom, rgba(247,248,250,0.9), transparent); }
|
||||||
|
.b2in-layer[data-theme="light"] .b2in-scrim-bottom { background: linear-gradient(to top, rgba(247,248,250,0.9), transparent); }
|
||||||
.b2in-layer[data-theme="light"] .b2in-text {
|
.b2in-layer[data-theme="light"] .b2in-text {
|
||||||
background: linear-gradient(to top, rgba(247,248,250,0.85) 40%, transparent);
|
background: linear-gradient(to top, rgba(247,248,250,0.85) 40%, transparent);
|
||||||
}
|
}
|
||||||
|
|
@ -325,6 +348,10 @@
|
||||||
font-size: 24px; color: #737373; text-align: right;
|
font-size: 24px; color: #737373; text-align: right;
|
||||||
line-height: 1.35; font-weight: 400;
|
line-height: 1.35; font-weight: 400;
|
||||||
}
|
}
|
||||||
|
.offer-price-note.strike {
|
||||||
|
color: #dc2626; text-decoration: line-through;
|
||||||
|
text-decoration-color: #dc2626; text-decoration-thickness: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
/* Bullets */
|
/* Bullets */
|
||||||
.offer-bullets {
|
.offer-bullets {
|
||||||
|
|
@ -411,6 +438,66 @@
|
||||||
.status-message { font-weight: 300; opacity: 0.7; }
|
.status-message { font-weight: 300; opacity: 0.7; }
|
||||||
.status-error { color: #ef4444; font-weight: 500; }
|
.status-error { color: #ef4444; font-weight: 500; }
|
||||||
.status-sub { font-size: 1.4vh; opacity: 0.4; margin-top: 1vh; }
|
.status-sub { font-size: 1.4vh; opacity: 0.4; margin-top: 1vh; }
|
||||||
|
|
||||||
|
.display-empty {
|
||||||
|
position: absolute; inset: 0; z-index: 10;
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
align-items: center; justify-content: center;
|
||||||
|
text-align: center; padding: 6vw;
|
||||||
|
background: #000; color: #fff;
|
||||||
|
}
|
||||||
|
.display-empty__title { font-size: 2.4vh; font-weight: 600; }
|
||||||
|
.display-empty__hint { font-size: 1.6vh; opacity: 0.5; margin-top: 1.2vh; font-weight: 300; }
|
||||||
|
|
||||||
|
.display-overview {
|
||||||
|
position: fixed; inset: 0; z-index: 10000;
|
||||||
|
overflow-y: auto; background: radial-gradient(circle at top, #12364d 0, #05070a 42%, #000 100%);
|
||||||
|
color: #fff; cursor: auto; padding: clamp(24px, 5vw, 72px);
|
||||||
|
}
|
||||||
|
.display-overview.hidden { display: none; }
|
||||||
|
.display-overview__inner { width: min(1120px, 100%); margin: 0 auto; }
|
||||||
|
.display-overview__eyebrow {
|
||||||
|
color: #38bdf8; font-size: 13px; font-weight: 700;
|
||||||
|
letter-spacing: 0.16em; text-transform: uppercase; margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.display-overview h1 {
|
||||||
|
font-size: clamp(34px, 6vw, 76px); line-height: 0.95;
|
||||||
|
letter-spacing: -0.05em; margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
.display-overview__intro {
|
||||||
|
max-width: 720px; color: rgba(255,255,255,0.68);
|
||||||
|
font-size: clamp(16px, 2vw, 22px); line-height: 1.5; margin-bottom: 36px;
|
||||||
|
}
|
||||||
|
.display-overview__grid {
|
||||||
|
display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
.display-card {
|
||||||
|
display: flex; flex-direction: column; gap: 16px;
|
||||||
|
min-height: 220px; padding: 24px; border-radius: 28px;
|
||||||
|
border: 1px solid rgba(255,255,255,0.14);
|
||||||
|
background: rgba(255,255,255,0.08); color: #fff; text-decoration: none;
|
||||||
|
box-shadow: 0 24px 70px rgba(0,0,0,0.24);
|
||||||
|
transition: transform 0.18s ease, border-color 0.18s ease, background 0.18s ease;
|
||||||
|
}
|
||||||
|
.display-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
border-color: rgba(56,189,248,0.55);
|
||||||
|
background: rgba(255,255,255,0.12);
|
||||||
|
}
|
||||||
|
.display-card__badges { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||||
|
.display-badge {
|
||||||
|
border-radius: 999px; padding: 6px 10px; font-size: 12px; font-weight: 700;
|
||||||
|
background: rgba(34,197,94,0.18); color: #86efac; border: 1px solid rgba(134,239,172,0.28);
|
||||||
|
}
|
||||||
|
.display-badge--live { background: rgba(56,189,248,0.18); color: #7dd3fc; border-color: rgba(125,211,252,0.28); }
|
||||||
|
.display-card__title { font-size: 28px; font-weight: 700; letter-spacing: -0.03em; }
|
||||||
|
.display-card__meta { display: grid; gap: 6px; color: rgba(255,255,255,0.62); font-size: 15px; }
|
||||||
|
.display-card__action { margin-top: auto; color: #7dd3fc; font-weight: 700; }
|
||||||
|
.display-overview__empty {
|
||||||
|
border: 1px dashed rgba(255,255,255,0.24); border-radius: 28px;
|
||||||
|
padding: 32px; color: rgba(255,255,255,0.62);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
@ -434,6 +521,18 @@
|
||||||
<div class="status-sub">Neustart in Kürze...</div>
|
<div class="status-sub">Neustart in Kürze...</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="display-overview hidden" id="display-overview">
|
||||||
|
<div class="display-overview__inner">
|
||||||
|
<div class="display-overview__eyebrow">Cabinet Display Player</div>
|
||||||
|
<h1>Aktive Live-Displays</h1>
|
||||||
|
<p class="display-overview__intro">
|
||||||
|
Wählen Sie ein Display aus, um die veröffentlichte Live-Bespielung zu öffnen.
|
||||||
|
Angezeigt werden nur aktive Displays mit veröffentlichter Live-Konfiguration.
|
||||||
|
</p>
|
||||||
|
<div class="display-overview__grid" id="display-overview-list"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
function escapeHtml(value) {
|
function escapeHtml(value) {
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
|
|
@ -462,15 +561,12 @@ class DisplayPlayer {
|
||||||
this.moduleId = this.detectModuleId();
|
this.moduleId = this.detectModuleId();
|
||||||
this.itemId = this.detectItemId();
|
this.itemId = this.detectItemId();
|
||||||
this.displayId = this.detectDisplayId();
|
this.displayId = this.detectDisplayId();
|
||||||
if (!this.displayId && !this.previewToken && !this.moduleId) {
|
|
||||||
this.showError('Keine Display-ID oder Vorschau angegeben. URL: /display/index.html?id=1');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// API
|
// API
|
||||||
this.BASE_URL = this.detectBaseUrl();
|
this.BASE_URL = this.detectBaseUrl();
|
||||||
this.API_CONFIG = this.detectConfigUrl();
|
this.API_CONFIG = this.detectConfigUrl();
|
||||||
this.API_CHECK = this.detectCheckUrl();
|
this.API_CHECK = this.detectCheckUrl();
|
||||||
|
this.API_OVERVIEW = `${this.BASE_URL}/api/display/overview`;
|
||||||
|
|
||||||
// Timing
|
// Timing
|
||||||
this.POLL_INTERVAL = 60000;
|
this.POLL_INTERVAL = 60000;
|
||||||
|
|
@ -490,6 +586,7 @@ class DisplayPlayer {
|
||||||
this.lastSuccessTime = Date.now();
|
this.lastSuccessTime = Date.now();
|
||||||
this.isRunning = false;
|
this.isRunning = false;
|
||||||
this.activeVersionRenderer = null;
|
this.activeVersionRenderer = null;
|
||||||
|
this.emptyVersionStreak = 0;
|
||||||
|
|
||||||
// DOM
|
// DOM
|
||||||
this.viewport = document.getElementById('viewport');
|
this.viewport = document.getElementById('viewport');
|
||||||
|
|
@ -497,6 +594,8 @@ class DisplayPlayer {
|
||||||
this.loadingInfo = document.getElementById('loading-info');
|
this.loadingInfo = document.getElementById('loading-info');
|
||||||
this.errorOverlay = document.getElementById('error-overlay');
|
this.errorOverlay = document.getElementById('error-overlay');
|
||||||
this.errorMessage = document.getElementById('error-message');
|
this.errorMessage = document.getElementById('error-message');
|
||||||
|
this.overviewOverlay = document.getElementById('display-overview');
|
||||||
|
this.overviewList = document.getElementById('display-overview-list');
|
||||||
|
|
||||||
this.loadingInfo.textContent = this.detectLoadingLabel();
|
this.loadingInfo.textContent = this.detectLoadingLabel();
|
||||||
|
|
||||||
|
|
@ -583,13 +682,16 @@ class DisplayPlayer {
|
||||||
}
|
}
|
||||||
return `Modul #${this.moduleId}`;
|
return `Modul #${this.moduleId}`;
|
||||||
}
|
}
|
||||||
|
if (!this.displayId) {
|
||||||
|
return 'Display-Übersicht';
|
||||||
|
}
|
||||||
return `Display #${this.displayId}`;
|
return `Display #${this.displayId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
detectBaseUrl() {
|
detectBaseUrl() {
|
||||||
const hostname = window.location.hostname;
|
const hostname = window.location.hostname;
|
||||||
if (hostname === 'cabinet.b2in.eu' || hostname.includes('b2in.eu')) {
|
if (hostname === 'cabinet.b2in.eu') {
|
||||||
return 'https://b2in.eu';
|
return 'https://portal.b2in.eu';
|
||||||
}
|
}
|
||||||
return window.location.origin;
|
return window.location.origin;
|
||||||
}
|
}
|
||||||
|
|
@ -602,6 +704,11 @@ class DisplayPlayer {
|
||||||
console.log(`[Display] Initializing ${this.detectLoadingLabel()}`);
|
console.log(`[Display] Initializing ${this.detectLoadingLabel()}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (!this.displayId && !this.previewToken && !this.moduleId) {
|
||||||
|
await this.fetchOverview();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await this.fetchConfig();
|
await this.fetchConfig();
|
||||||
|
|
||||||
if (this.playlist.length === 0) {
|
if (this.playlist.length === 0) {
|
||||||
|
|
@ -642,6 +749,16 @@ class DisplayPlayer {
|
||||||
console.log(`[Display] Loaded ${this.playlist.length} version(s)`);
|
console.log(`[Display] Loaded ${this.playlist.length} version(s)`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fetchOverview() {
|
||||||
|
const response = await fetch(this.API_OVERVIEW);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
this.renderOverview(data.displays || []);
|
||||||
|
}
|
||||||
|
|
||||||
startPolling() {
|
startPolling() {
|
||||||
if (!this.API_CHECK) {
|
if (!this.API_CHECK) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -731,6 +848,19 @@ class DisplayPlayer {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Guard against a delay-free infinite restart loop when no version in the
|
||||||
|
// playlist has any playable content (e.g. a freshly created, empty module).
|
||||||
|
if (!this.versionHasContent(version)) {
|
||||||
|
this.emptyVersionStreak++;
|
||||||
|
if (this.emptyVersionStreak >= this.playlist.length) {
|
||||||
|
this.showEmptyPlaylist();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.advanceVersion();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.emptyVersionStreak = 0;
|
||||||
|
|
||||||
console.log(`[Display] Playing version ${this.currentVersionIndex + 1}/${this.playlist.length}: ${version.version_name} (${version.type})`);
|
console.log(`[Display] Playing version ${this.currentVersionIndex + 1}/${this.playlist.length}: ${version.version_name} (${version.type})`);
|
||||||
|
|
||||||
// Clean up previous renderer
|
// Clean up previous renderer
|
||||||
|
|
@ -771,6 +901,38 @@ class DisplayPlayer {
|
||||||
this.playCurrentVersion();
|
this.playCurrentVersion();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
versionHasContent(version) {
|
||||||
|
if (!version) return false;
|
||||||
|
switch (version.type) {
|
||||||
|
case 'video-display':
|
||||||
|
return (version.videoPlaylist || []).length > 0;
|
||||||
|
case 'b2in':
|
||||||
|
return (version.items || []).some(item => item.is_active);
|
||||||
|
case 'offers':
|
||||||
|
return (version.slides || []).length > 0;
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
showEmptyPlaylist() {
|
||||||
|
this.isRunning = false;
|
||||||
|
this.emptyVersionStreak = 0;
|
||||||
|
|
||||||
|
if (this.activeVersionRenderer) {
|
||||||
|
this.activeVersionRenderer.destroy();
|
||||||
|
this.activeVersionRenderer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.viewport.innerHTML = `
|
||||||
|
<div class="display-empty">
|
||||||
|
<p class="display-empty__title">Noch keine Inhalte vorhanden</p>
|
||||||
|
<p class="display-empty__hint">Sobald Inhalte angelegt und aktiviert sind, erscheinen sie hier.</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
console.log('[Display] Playlist has no playable content – playback stopped.');
|
||||||
|
}
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// UI HELPERS
|
// UI HELPERS
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
@ -779,11 +941,50 @@ class DisplayPlayer {
|
||||||
this.loadingOverlay.classList.add('hidden');
|
this.loadingOverlay.classList.add('hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
renderOverview(displays) {
|
||||||
|
this.hideLoading();
|
||||||
|
this.errorOverlay.classList.add('hidden');
|
||||||
|
this.overviewOverlay.classList.remove('hidden');
|
||||||
|
|
||||||
|
if (displays.length === 0) {
|
||||||
|
this.overviewList.innerHTML = `
|
||||||
|
<div class="display-overview__empty">
|
||||||
|
Es sind aktuell keine aktiven Live-Displays veröffentlicht.
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.overviewList.innerHTML = displays.map(display => `
|
||||||
|
<a class="display-card" href="${this.escapeHtml(display.url)}">
|
||||||
|
<div class="display-card__badges">
|
||||||
|
<span class="display-badge">Aktiv</span>
|
||||||
|
<span class="display-badge display-badge--live">Live</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="display-card__title">${this.escapeHtml(display.name)}</div>
|
||||||
|
<div class="display-card__meta">
|
||||||
|
<span>Display-ID: ${this.escapeHtml(display.id)}</span>
|
||||||
|
${display.location ? `<span>Standort: ${this.escapeHtml(display.location)}</span>` : ''}
|
||||||
|
<span>${this.escapeHtml(display.module_count)} Modul(e) veröffentlicht</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="display-card__action">Display öffnen</div>
|
||||||
|
</a>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
showError(msg) {
|
showError(msg) {
|
||||||
this.loadingOverlay.classList.add('hidden');
|
this.loadingOverlay.classList.add('hidden');
|
||||||
this.errorOverlay.classList.remove('hidden');
|
this.errorOverlay.classList.remove('hidden');
|
||||||
this.errorMessage.textContent = msg;
|
this.errorMessage.textContent = msg;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
escapeHtml(value) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = value ?? '';
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1014,7 +1215,7 @@ class B2inRenderer {
|
||||||
|
|
||||||
build() {
|
build() {
|
||||||
const layer = document.createElement('div');
|
const layer = document.createElement('div');
|
||||||
layer.className = 'version-layer b2in-layer active';
|
layer.className = 'version-layer b2in-layer active' + (this.settings.show_footer === false ? ' no-footer' : '');
|
||||||
layer.setAttribute('data-theme', this.theme);
|
layer.setAttribute('data-theme', this.theme);
|
||||||
|
|
||||||
const headerLogoUrl = this.resolveUrl(this.settings.header_logo_url || '../assets/b2in-logo-positive.svg');
|
const headerLogoUrl = this.resolveUrl(this.settings.header_logo_url || '../assets/b2in-logo-positive.svg');
|
||||||
|
|
@ -1027,11 +1228,57 @@ class B2inRenderer {
|
||||||
: '';
|
: '';
|
||||||
const qrUrl = normalizeQrUrl(this.settings.qr_url || footerUrl || 'b2in.eu');
|
const qrUrl = normalizeQrUrl(this.settings.qr_url || footerUrl || 'b2in.eu');
|
||||||
|
|
||||||
layer.innerHTML = `
|
// Footer visibility, brand element visibility + corner positioning
|
||||||
<header class="b2in-header">
|
const showFooter = this.settings.show_footer !== false;
|
||||||
|
const showLogo = this.settings.show_logo !== false;
|
||||||
|
const showClaim = this.settings.show_claim !== false;
|
||||||
|
const validPositions = ['top-left', 'top-right', 'bottom-left', 'bottom-right'];
|
||||||
|
let logoPos = validPositions.includes(this.settings.logo_position) ? this.settings.logo_position : 'top-left';
|
||||||
|
let claimPos = validPositions.includes(this.settings.claim_position) ? this.settings.claim_position : 'top-right';
|
||||||
|
|
||||||
|
// Bottom corners only allowed when the footer is hidden
|
||||||
|
if (showFooter) {
|
||||||
|
if (logoPos.startsWith('bottom')) logoPos = logoPos.replace('bottom-', 'top-');
|
||||||
|
if (claimPos.startsWith('bottom')) claimPos = claimPos.replace('bottom-', 'top-');
|
||||||
|
}
|
||||||
|
// Claim must not share the logo corner
|
||||||
|
if (claimPos === logoPos) {
|
||||||
|
claimPos = (validPositions.filter(p => (showFooter ? p.startsWith('top') : true) && p !== logoPos)[0]) || claimPos;
|
||||||
|
}
|
||||||
|
|
||||||
|
const logoVisible = showLogo;
|
||||||
|
const claimVisible = showClaim && !!headerClaim;
|
||||||
|
|
||||||
|
const hasTop = (logoVisible && logoPos.startsWith('top')) || (claimVisible && claimPos.startsWith('top'));
|
||||||
|
const hasBottom = (logoVisible && logoPos.startsWith('bottom')) || (claimVisible && claimPos.startsWith('bottom'));
|
||||||
|
|
||||||
|
const logoHtml = logoVisible
|
||||||
|
? `<div class="b2in-brand b2in-brand-logo pos-${logoPos}">
|
||||||
<img src="${escapeHtml(headerLogoUrl)}" alt="B2in">
|
<img src="${escapeHtml(headerLogoUrl)}" alt="B2in">
|
||||||
<span class="b2in-claim">${escapeHtml(headerClaim)}</span>
|
</div>`
|
||||||
</header>
|
: '';
|
||||||
|
|
||||||
|
const claimHtml = claimVisible
|
||||||
|
? `<div class="b2in-brand b2in-brand-claim pos-${claimPos}">${escapeHtml(headerClaim)}</div>`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const footerHtml = showFooter
|
||||||
|
? `<footer class="b2in-footer">
|
||||||
|
<div>
|
||||||
|
<span class="b2in-footer-url">${escapeHtml(footerUrl)}</span>
|
||||||
|
${footerNameHtml}
|
||||||
|
</div>
|
||||||
|
<div class="b2in-footer-qr">
|
||||||
|
<img src="https://api.qrserver.com/v1/create-qr-code/?size=200x200&margin=5&data=${encodeURIComponent(qrUrl)}" alt="QR">
|
||||||
|
</div>
|
||||||
|
</footer>`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
layer.innerHTML = `
|
||||||
|
${hasTop ? '<div class="b2in-scrim b2in-scrim-top"></div>' : ''}
|
||||||
|
${(hasBottom && !showFooter) ? '<div class="b2in-scrim b2in-scrim-bottom"></div>' : ''}
|
||||||
|
${logoHtml}
|
||||||
|
${claimHtml}
|
||||||
<section class="b2in-media">
|
<section class="b2in-media">
|
||||||
<div class="b2in-media-layer active" id="b2in-layer-a"></div>
|
<div class="b2in-media-layer active" id="b2in-layer-a"></div>
|
||||||
<div class="b2in-media-layer" id="b2in-layer-b"></div>
|
<div class="b2in-media-layer" id="b2in-layer-b"></div>
|
||||||
|
|
@ -1040,15 +1287,7 @@ class B2inRenderer {
|
||||||
<div class="b2in-headline" id="b2in-headline"></div>
|
<div class="b2in-headline" id="b2in-headline"></div>
|
||||||
<div class="b2in-subline" id="b2in-subline"></div>
|
<div class="b2in-subline" id="b2in-subline"></div>
|
||||||
</section>
|
</section>
|
||||||
<footer class="b2in-footer">
|
${footerHtml}
|
||||||
<div>
|
|
||||||
<span class="b2in-footer-url">${escapeHtml(footerUrl)}</span>
|
|
||||||
${footerNameHtml}
|
|
||||||
</div>
|
|
||||||
<div class="b2in-footer-qr">
|
|
||||||
<img src="https://api.qrserver.com/v1/create-qr-code/?size=200x200&margin=5&data=${encodeURIComponent(qrUrl)}" alt="QR">
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
<div class="b2in-progress-track">
|
<div class="b2in-progress-track">
|
||||||
<div class="b2in-progress-fill" id="b2in-progress"></div>
|
<div class="b2in-progress-fill" id="b2in-progress"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1257,42 +1496,64 @@ class OffersRenderer {
|
||||||
}
|
}
|
||||||
|
|
||||||
buildSlide(slide) {
|
buildSlide(slide) {
|
||||||
|
// Single dynamic detail layout: every block is toggleable. Older slides
|
||||||
|
// without explicit show_* flags fall back to "is there content?".
|
||||||
|
const has = v => v !== undefined && v !== null && v !== '';
|
||||||
|
const qrUrl = slide.qr_url || this.settings.footer_url || '';
|
||||||
|
const contactText = slide.contact || this.settings.footer_claim || '';
|
||||||
|
const show = {
|
||||||
|
logo: slide.show_logo ?? true,
|
||||||
|
badge: (slide.show_badge ?? has(slide.badge_text)) && has(slide.badge_text),
|
||||||
|
eyebrow: (slide.show_eyebrow ?? has(slide.eyebrow)) && has(slide.eyebrow),
|
||||||
|
subline: (slide.show_subline ?? has(slide.subline)) && has(slide.subline),
|
||||||
|
bullets: (slide.show_bullets ?? (slide.bullets && slide.bullets.length > 0)) && (slide.bullets && slide.bullets.length > 0),
|
||||||
|
price: (slide.show_price ?? has(slide.price)) && has(slide.price),
|
||||||
|
disclaimer: (slide.show_disclaimer ?? has(slide.disclaimer)) && has(slide.disclaimer),
|
||||||
|
qr: (slide.show_qr ?? has(slide.qr_url)) && has(qrUrl),
|
||||||
|
contact: (slide.show_contact ?? has(slide.contact)) && has(contactText),
|
||||||
|
};
|
||||||
|
|
||||||
const wrapper = document.createElement('div');
|
const wrapper = document.createElement('div');
|
||||||
wrapper.className = 'offers-slide-container';
|
wrapper.className = 'offers-slide-container';
|
||||||
|
|
||||||
const article = document.createElement('article');
|
const article = document.createElement('article');
|
||||||
article.className = 'offer-slide';
|
article.className = 'offer-slide';
|
||||||
|
|
||||||
// --- HEADER ---
|
// --- HEADER (Logo & Marke) – per slide, toggleable ---
|
||||||
const header = document.createElement('header');
|
if (show.logo) {
|
||||||
header.className = 'offer-header';
|
const header = document.createElement('header');
|
||||||
|
header.className = 'offer-header';
|
||||||
|
|
||||||
const brand = document.createElement('div');
|
const brand = document.createElement('div');
|
||||||
brand.className = 'offer-brand';
|
brand.className = 'offer-brand';
|
||||||
const brandLogo = document.createElement('img');
|
const brandLogo = document.createElement('img');
|
||||||
brandLogo.src = this.resolveUrl(this.settings.logo_url || '../logo-cabinet-300.png');
|
brandLogo.src = this.resolveUrl(slide.logo_url || '../logo-cabinet-300.png');
|
||||||
brandLogo.alt = 'CABINET';
|
brandLogo.alt = 'Logo';
|
||||||
brandLogo.className = 'offer-brand-logo';
|
brandLogo.className = 'offer-brand-logo';
|
||||||
brand.appendChild(brandLogo);
|
brand.appendChild(brandLogo);
|
||||||
|
|
||||||
if (slide.show_brand_text) {
|
if (has(slide.brand_text)) {
|
||||||
const brandText = document.createElement('span');
|
const brandText = document.createElement('span');
|
||||||
brandText.className = 'offer-brand-text';
|
brandText.className = 'offer-brand-text';
|
||||||
brandText.textContent = this.settings.brand_text || 'Bielefeld';
|
brandText.textContent = slide.brand_text;
|
||||||
brand.appendChild(brandText);
|
brand.appendChild(brandText);
|
||||||
|
}
|
||||||
|
|
||||||
|
header.appendChild(brand);
|
||||||
|
|
||||||
|
if (has(slide.brand_tagline)) {
|
||||||
|
const tagline = document.createElement('div');
|
||||||
|
tagline.className = 'offer-tagline';
|
||||||
|
tagline.innerHTML = slide.brand_tagline.replace(/\n/g, '<br>');
|
||||||
|
header.appendChild(tagline);
|
||||||
|
}
|
||||||
|
|
||||||
|
article.appendChild(header);
|
||||||
|
} else {
|
||||||
|
// Without the header row, let the hero stay the flexible middle row.
|
||||||
|
article.style.gridTemplateRows = '1fr auto';
|
||||||
}
|
}
|
||||||
|
|
||||||
header.appendChild(brand);
|
|
||||||
|
|
||||||
if (slide.show_brand_text && slide.brand_tagline) {
|
|
||||||
const tagline = document.createElement('div');
|
|
||||||
tagline.className = 'offer-tagline';
|
|
||||||
tagline.innerHTML = slide.brand_tagline.replace(/\n/g, '<br>');
|
|
||||||
header.appendChild(tagline);
|
|
||||||
}
|
|
||||||
|
|
||||||
article.appendChild(header);
|
|
||||||
|
|
||||||
// --- HERO ---
|
// --- HERO ---
|
||||||
const hero = document.createElement('section');
|
const hero = document.createElement('section');
|
||||||
hero.className = 'offer-hero';
|
hero.className = 'offer-hero';
|
||||||
|
|
@ -1301,7 +1562,7 @@ class OffersRenderer {
|
||||||
hero.style.background = `url('${imgUrl}') center/cover no-repeat`;
|
hero.style.background = `url('${imgUrl}') center/cover no-repeat`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (slide.badge_text) {
|
if (show.badge) {
|
||||||
const badge = document.createElement('span');
|
const badge = document.createElement('span');
|
||||||
badge.className = 'offer-hero-badge large';
|
badge.className = 'offer-hero-badge large';
|
||||||
badge.textContent = slide.badge_text;
|
badge.textContent = slide.badge_text;
|
||||||
|
|
@ -1321,7 +1582,7 @@ class OffersRenderer {
|
||||||
const infoContent = document.createElement('div');
|
const infoContent = document.createElement('div');
|
||||||
infoContent.className = 'offer-info-content';
|
infoContent.className = 'offer-info-content';
|
||||||
|
|
||||||
if (slide.eyebrow) {
|
if (show.eyebrow) {
|
||||||
const eyebrow = document.createElement('p');
|
const eyebrow = document.createElement('p');
|
||||||
eyebrow.className = 'offer-eyebrow';
|
eyebrow.className = 'offer-eyebrow';
|
||||||
eyebrow.textContent = slide.eyebrow;
|
eyebrow.textContent = slide.eyebrow;
|
||||||
|
|
@ -1330,22 +1591,19 @@ class OffersRenderer {
|
||||||
|
|
||||||
if (slide.title) {
|
if (slide.title) {
|
||||||
const title = document.createElement('h1');
|
const title = document.createElement('h1');
|
||||||
const titleSize = (slide.type === 'product-details') ? 'medium' : 'large';
|
title.className = 'offer-title medium';
|
||||||
title.className = `offer-title ${titleSize}`;
|
|
||||||
title.innerHTML = slide.title.replace(/\n/g, '<br>');
|
title.innerHTML = slide.title.replace(/\n/g, '<br>');
|
||||||
infoContent.appendChild(title);
|
infoContent.appendChild(title);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Subline (product-impulse)
|
if (show.subline) {
|
||||||
if (slide.subline) {
|
|
||||||
const subline = document.createElement('p');
|
const subline = document.createElement('p');
|
||||||
subline.className = 'offer-subline';
|
subline.className = 'offer-subline';
|
||||||
subline.textContent = slide.subline;
|
subline.textContent = slide.subline;
|
||||||
infoContent.appendChild(subline);
|
infoContent.appendChild(subline);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bullets (product-details)
|
if (show.bullets) {
|
||||||
if (slide.bullets && slide.bullets.length > 0) {
|
|
||||||
const ul = document.createElement('ul');
|
const ul = document.createElement('ul');
|
||||||
ul.className = 'offer-bullets';
|
ul.className = 'offer-bullets';
|
||||||
slide.bullets.forEach(text => {
|
slide.bullets.forEach(text => {
|
||||||
|
|
@ -1359,8 +1617,8 @@ class OffersRenderer {
|
||||||
|
|
||||||
info.appendChild(infoContent);
|
info.appendChild(infoContent);
|
||||||
|
|
||||||
// Price block (product-hero, product-impulse)
|
// Price block
|
||||||
if (slide.price) {
|
if (show.price) {
|
||||||
const priceBlock = document.createElement('div');
|
const priceBlock = document.createElement('div');
|
||||||
priceBlock.className = 'offer-price-block';
|
priceBlock.className = 'offer-price-block';
|
||||||
|
|
||||||
|
|
@ -1374,7 +1632,7 @@ class OffersRenderer {
|
||||||
|
|
||||||
if (slide.original_price) {
|
if (slide.original_price) {
|
||||||
const note = document.createElement('div');
|
const note = document.createElement('div');
|
||||||
note.className = 'offer-price-note';
|
note.className = slide.strike_original_price ? 'offer-price-note strike' : 'offer-price-note';
|
||||||
note.textContent = slide.original_price;
|
note.textContent = slide.original_price;
|
||||||
priceRow.appendChild(note);
|
priceRow.appendChild(note);
|
||||||
}
|
}
|
||||||
|
|
@ -1393,8 +1651,8 @@ class OffersRenderer {
|
||||||
info.appendChild(priceBlock);
|
info.appendChild(priceBlock);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Disclaimer (intro)
|
// Disclaimer
|
||||||
if (slide.disclaimer) {
|
if (show.disclaimer) {
|
||||||
const footer = document.createElement('div');
|
const footer = document.createElement('div');
|
||||||
footer.className = 'offer-info-footer';
|
footer.className = 'offer-info-footer';
|
||||||
const disc = document.createElement('span');
|
const disc = document.createElement('span');
|
||||||
|
|
@ -1406,38 +1664,41 @@ class OffersRenderer {
|
||||||
|
|
||||||
bottom.appendChild(info);
|
bottom.appendChild(info);
|
||||||
|
|
||||||
// QR Box
|
// QR / Contact box – only when at least one of the two is enabled.
|
||||||
const qrBox = document.createElement('aside');
|
if (show.qr || show.contact) {
|
||||||
qrBox.className = 'offer-qr-box';
|
const qrBox = document.createElement('aside');
|
||||||
|
qrBox.className = 'offer-qr-box';
|
||||||
|
|
||||||
const qrHeader = document.createElement('div');
|
if (show.qr) {
|
||||||
qrHeader.className = 'offer-qr-header';
|
const qrHeader = document.createElement('div');
|
||||||
qrHeader.innerHTML = `
|
qrHeader.className = 'offer-qr-header';
|
||||||
<p class="offer-qr-title">${this.escapeHtml(slide.qr_title || this.settings.qr_default_title || 'Kontakt')}</p>
|
qrHeader.innerHTML = `
|
||||||
<p class="offer-qr-subtitle">${this.escapeHtml(this.settings.qr_subtitle || 'QR scannen')}</p>
|
<p class="offer-qr-title">${this.escapeHtml(slide.qr_title || this.settings.qr_default_title || 'Kontakt')}</p>
|
||||||
`;
|
<p class="offer-qr-subtitle">${this.escapeHtml(this.settings.qr_subtitle || 'QR scannen')}</p>
|
||||||
qrBox.appendChild(qrHeader);
|
`;
|
||||||
|
qrBox.appendChild(qrHeader);
|
||||||
|
|
||||||
const qrWrapper = document.createElement('div');
|
const qrWrapper = document.createElement('div');
|
||||||
qrWrapper.className = 'offer-qr-wrapper';
|
qrWrapper.className = 'offer-qr-wrapper';
|
||||||
const qrUrl = slide.qr_url || this.settings.footer_url || '';
|
const qrImg = document.createElement('img');
|
||||||
if (qrUrl) {
|
qrImg.src = `https://api.qrserver.com/v1/create-qr-code/?size=300x300&color=000000&bgcolor=ffffff&margin=8&data=${encodeURIComponent(normalizeQrUrl(qrUrl))}`;
|
||||||
const qrImg = document.createElement('img');
|
qrImg.alt = 'QR Code';
|
||||||
qrImg.src = `https://api.qrserver.com/v1/create-qr-code/?size=300x300&color=000000&bgcolor=ffffff&margin=8&data=${encodeURIComponent(normalizeQrUrl(qrUrl))}`;
|
qrWrapper.appendChild(qrImg);
|
||||||
qrImg.alt = 'QR Code';
|
qrBox.appendChild(qrWrapper);
|
||||||
qrWrapper.appendChild(qrImg);
|
}
|
||||||
}
|
|
||||||
qrBox.appendChild(qrWrapper);
|
|
||||||
|
|
||||||
const contactText = slide.contact || this.settings.footer_claim || '';
|
if (show.contact) {
|
||||||
if (contactText) {
|
const contact = document.createElement('p');
|
||||||
const contact = document.createElement('p');
|
contact.className = 'offer-qr-contact';
|
||||||
contact.className = 'offer-qr-contact';
|
contact.innerHTML = contactText.replace(/\n/g, '<br>');
|
||||||
contact.innerHTML = contactText.replace(/\n/g, '<br>');
|
qrBox.appendChild(contact);
|
||||||
qrBox.appendChild(contact);
|
}
|
||||||
|
|
||||||
|
bottom.appendChild(qrBox);
|
||||||
|
} else {
|
||||||
|
bottom.style.gridTemplateColumns = '1fr';
|
||||||
}
|
}
|
||||||
|
|
||||||
bottom.appendChild(qrBox);
|
|
||||||
article.appendChild(bottom);
|
article.appendChild(bottom);
|
||||||
wrapper.appendChild(article);
|
wrapper.appendChild(article);
|
||||||
|
|
||||||
|
|
|
||||||
13171
public/flux/flux.js
13171
public/flux/flux.js
File diff suppressed because it is too large
Load diff
|
|
@ -119,6 +119,7 @@
|
||||||
|
|
||||||
{{ $slot }}
|
{{ $slot }}
|
||||||
|
|
||||||
|
@livewireScripts
|
||||||
@fluxScripts
|
@fluxScripts
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -183,7 +183,7 @@
|
||||||
|
|
||||||
<flux:navlist.group :heading="__('Cabinet')" class="grid mb-4">
|
<flux:navlist.group :heading="__('Cabinet')" class="grid mb-4">
|
||||||
<flux:navlist.group expandable
|
<flux:navlist.group expandable
|
||||||
:expanded="request()->routeIs(['admin.cms.display-dashboard', 'admin.cms.display-media', 'admin.cms.display-modules', 'admin.cms.display-module-edit', 'admin.cms.display-versions', 'admin.cms.display-version-edit', 'admin.cms.displays', 'admin.cms.cabinet', 'admin.cms.cabinet-tablet'])"
|
:expanded="request()->routeIs(['admin.cms.display-dashboard', 'admin.cms.display-media', 'admin.cms.display-modules', 'admin.cms.display-module-edit', 'admin.cms.displays', 'admin.cms.cabinet', 'admin.cms.cabinet-tablet'])"
|
||||||
heading="Store Displays" class="grid">
|
heading="Store Displays" class="grid">
|
||||||
<flux:navlist.item icon="squares-2x2" :href="route('admin.cms.display-dashboard')"
|
<flux:navlist.item icon="squares-2x2" :href="route('admin.cms.display-dashboard')"
|
||||||
:current="request()->routeIs('admin.cms.display-dashboard')" wire:navigate>{{ __('Übersicht') }}
|
:current="request()->routeIs('admin.cms.display-dashboard')" wire:navigate>{{ __('Übersicht') }}
|
||||||
|
|
@ -192,7 +192,7 @@
|
||||||
:current="request()->routeIs('admin.cms.display-media')" wire:navigate>{{ __('Mediathek') }}
|
:current="request()->routeIs('admin.cms.display-media')" wire:navigate>{{ __('Mediathek') }}
|
||||||
</flux:navlist.item>
|
</flux:navlist.item>
|
||||||
<flux:navlist.item icon="rectangle-group" :href="route('admin.cms.display-modules')"
|
<flux:navlist.item icon="rectangle-group" :href="route('admin.cms.display-modules')"
|
||||||
:current="request()->routeIs(['admin.cms.display-modules', 'admin.cms.display-module-edit', 'admin.cms.display-versions', 'admin.cms.display-version-edit'])" wire:navigate>{{ __('Module') }}
|
:current="request()->routeIs(['admin.cms.display-modules', 'admin.cms.display-module-edit'])" wire:navigate>{{ __('Module') }}
|
||||||
</flux:navlist.item>
|
</flux:navlist.item>
|
||||||
<flux:navlist.item icon="tv" :href="route('admin.cms.displays')"
|
<flux:navlist.item icon="tv" :href="route('admin.cms.displays')"
|
||||||
:current="request()->routeIs('admin.cms.displays')" wire:navigate>{{ __('Displays') }}
|
:current="request()->routeIs('admin.cms.displays')" wire:navigate>{{ __('Displays') }}
|
||||||
|
|
@ -380,9 +380,9 @@
|
||||||
|
|
||||||
<flux:toast />
|
<flux:toast />
|
||||||
|
|
||||||
{{-- Flux vor Livewire: flux.js registriert Alpine.data('fluxModal') im alpine:init-Handler --}}
|
{{-- Reihenfolge laut Flux-Doku: Livewire stellt das gebündelte Alpine bereit, danach registriert flux.js seine Alpine-Komponenten (fluxModal, …). --}}
|
||||||
@fluxScripts
|
|
||||||
@livewireScripts
|
@livewireScripts
|
||||||
|
@fluxScripts
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -26,17 +26,5 @@
|
||||||
@livewireScripts
|
@livewireScripts
|
||||||
@fluxScripts
|
@fluxScripts
|
||||||
@include('partials.theme-toggle-script')
|
@include('partials.theme-toggle-script')
|
||||||
<script src="{{ asset('vendor/livewire/livewire.js') }}"></script>
|
|
||||||
|
|
||||||
<!-- Debug: Script-Status -->
|
|
||||||
<script>
|
|
||||||
console.log('Body Scripts geladen');
|
|
||||||
console.log('Livewire JS:', {{ file_exists(public_path('vendor/livewire/livewire.js')) ? 'true' : 'false' }});
|
|
||||||
if (typeof Livewire !== 'undefined') {
|
|
||||||
console.log('Livewire verfügbar:', true);
|
|
||||||
} else {
|
|
||||||
console.log('Livewire verfügbar:', false);
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@livewireScripts
|
||||||
@fluxScripts
|
@fluxScripts
|
||||||
@include('partials.theme-toggle-script')
|
@include('partials.theme-toggle-script')
|
||||||
</body>
|
</body>
|
||||||
|
|
|
||||||
27
resources/views/components/media-thumb.blade.php
Normal file
27
resources/views/components/media-thumb.blade.php
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
@props(['url' => '', 'size' => 'h-10 w-10'])
|
||||||
|
|
||||||
|
@php
|
||||||
|
$url = (string) $url;
|
||||||
|
$path = parse_url($url, PHP_URL_PATH) ?? '';
|
||||||
|
$ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
|
||||||
|
$isVideo = in_array($ext, ['mp4', 'webm', 'mov', 'm4v', 'ogv'], true);
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<div {{ $attributes->merge(['class' => "relative {$size} shrink-0 flex items-center justify-center overflow-hidden rounded-lg border border-zinc-200 bg-zinc-100 dark:border-zinc-700 dark:bg-zinc-800"]) }}>
|
||||||
|
@if($url === '')
|
||||||
|
<x-heroicon-o-photo class="h-5 w-5 text-zinc-300 dark:text-zinc-600" />
|
||||||
|
@elseif($isVideo)
|
||||||
|
<video class="h-full w-full object-cover" preload="metadata" muted playsinline>
|
||||||
|
<source src="{{ $url }}#t=1">
|
||||||
|
</video>
|
||||||
|
<span class="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||||
|
<x-heroicon-s-play class="h-4 w-4 text-white/90 drop-shadow" />
|
||||||
|
</span>
|
||||||
|
@else
|
||||||
|
<img src="{{ $url }}" alt="" class="h-full w-full object-cover" loading="lazy"
|
||||||
|
onerror="this.style.display='none';this.nextElementSibling.classList.remove('hidden');" />
|
||||||
|
<span class="hidden">
|
||||||
|
<x-heroicon-o-link class="h-5 w-5 text-zinc-400" />
|
||||||
|
</span>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
@ -171,9 +171,9 @@ $tabletStatus = computed(function () {
|
||||||
Es besteht aus drei Bereichen, die Sie über die Kacheln oben erreichen:
|
Es besteht aus drei Bereichen, die Sie über die Kacheln oben erreichen:
|
||||||
</p>
|
</p>
|
||||||
<ul class="mt-2 ml-5 list-disc space-y-1">
|
<ul class="mt-2 ml-5 list-disc space-y-1">
|
||||||
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Mediathek</strong> - Zentrale Verwaltung aller Bilder und Videos fuer die Displays. Dateien bis 200 MB direkt hochladen oder groessere Videos als externe URL (Google Drive, OneDrive) einbinden.</li>
|
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Mediathek</strong> - Zentrale Verwaltung aller Bilder, SVG-Logos und Videos fuer die Displays. Dateien bis 200 MB direkt hochladen oder groessere Videos als externe URL (Google Drive, OneDrive) einbinden.</li>
|
||||||
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Module</strong> – Wiederverwendbare Content-Pakete, die auf den Displays abgespielt werden. Jede Modul hat einen bestimmten Typ und enthält passende Inhalte (Videos, Bilder oder Angebots-Slides).</li>
|
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Module</strong> – Wiederverwendbare Content-Pakete, die auf den Displays abgespielt werden. Jedes Modul hat einen bestimmten Typ, passende Inhalte und eigene Meta-Einstellungen fuer Logo, Claim, Footer, QR-Code oder Theme.</li>
|
||||||
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Displays</strong> – Die physischen Bildschirme im Showroom. Jedem Display werden eine oder mehrere Module als Playlist zugewiesen.</li>
|
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Displays</strong> – Die physischen Bildschirme im Showroom. Pro Display gibt es einen Live-Stand und optional einen Entwurf, der separat vorbereitet, getestet und bewusst veröffentlicht wird.</li>
|
||||||
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Info-Tablet</strong> – Das Tablet an der Eingangstür des Showrooms. Hier verwalten Sie Öffnungszeiten, den aktuellen Store-Status und Hinweise für Besucher.</li>
|
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Info-Tablet</strong> – Das Tablet an der Eingangstür des Showrooms. Hier verwalten Sie Öffnungszeiten, den aktuellen Store-Status und Hinweise für Besucher.</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -190,10 +190,10 @@ $tabletStatus = computed(function () {
|
||||||
Sie ist unabhängig von der Website-Mediathek (Flux CMS) und speziell auf die Anforderungen der Displays zugeschnitten.
|
Sie ist unabhängig von der Website-Mediathek (Flux CMS) und speziell auf die Anforderungen der Displays zugeschnitten.
|
||||||
</p>
|
</p>
|
||||||
<ul class="mt-2 ml-5 list-disc space-y-1">
|
<ul class="mt-2 ml-5 list-disc space-y-1">
|
||||||
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Direkt-Upload:</strong> Bilder und Videos bis 200 MB direkt per Drag-and-drop oder Dateiauswahl hochladen. Die Dateien werden auf dem Server gespeichert und stehen sofort zur Verfügung.</li>
|
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Direkt-Upload:</strong> Bilder, SVG-Dateien und Videos bis 200 MB direkt per Drag-and-drop oder Dateiauswahl hochladen. Die Dateien werden auf dem Server gespeichert und stehen sofort zur Verfügung.</li>
|
||||||
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Externe URLs:</strong> Für Videos über 200 MB (z. B. 4K-Showroom-Rundgänge) können Sie einen Freigabe-Link von Google Drive, OneDrive oder anderen Cloud-Diensten hinterlegen. Diese URL wird wie ein normales Medium in der Mediathek verwaltet und kann genauso in Module eingebunden werden.</li>
|
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Externe URLs:</strong> Für Videos über 200 MB (z. B. 4K-Showroom-Rundgänge) können Sie einen Freigabe-Link von Google Drive, OneDrive oder anderen Cloud-Diensten hinterlegen. Diese URL wird wie ein normales Medium in der Mediathek verwaltet und kann genauso in Module eingebunden werden.</li>
|
||||||
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Sammlungen:</strong> Ordnen Sie Medien in Sammlungen wie <em>immobilien</em>, <em>moebel</em> oder <em>brand</em>, um bei vielen Dateien den Überblick zu behalten.</li>
|
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Sammlungen:</strong> Ordnen Sie Medien in Sammlungen wie <em>immobilien</em>, <em>moebel</em> oder <em>brand</em>, um bei vielen Dateien den Überblick zu behalten.</li>
|
||||||
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Medienauswahl im Editor:</strong> Beim Bearbeiten eines Moduls erscheint ein „Aus Mediathek"-Button. Darüber öffnen Sie die Medienauswahl und können bestehende Medien wählen oder direkt neue hochladen.</li>
|
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Medienauswahl im Editor:</strong> Beim Bearbeiten eines Moduls erscheint ein „Aus Mediathek"-Button. Darüber öffnen Sie die Medienauswahl und können bestehende Medien wählen oder direkt neue Dateien inklusive SVG-Logos hochladen.</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -211,19 +211,19 @@ $tabletStatus = computed(function () {
|
||||||
<ul class="mt-2 ml-5 list-disc space-y-1">
|
<ul class="mt-2 ml-5 list-disc space-y-1">
|
||||||
<li>
|
<li>
|
||||||
<strong class="font-medium text-zinc-800 dark:text-zinc-200">Video-Display</strong> –
|
<strong class="font-medium text-zinc-800 dark:text-zinc-200">Video-Display</strong> –
|
||||||
Für Video-Playlists mit optionalem Footer. Inhalte: <em>Videos</em> (Dateiname, Titel, Position/Ausschnitt) und <em>Footer-Zeilen</em> (Überschrift, Unterzeile, optionaler QR-Code-Link).
|
Für Video-Playlists mit optionalem Footer. Inhalte: <em>Videos</em> aus der Mediathek oder Legacy-Dateinamen, Position/Ausschnitt und <em>Footer-Zeilen</em> (Überschrift, Unterzeile, optionaler QR-Code-Link). Mediathek-URLs wie <code class="text-xs bg-zinc-100 dark:bg-zinc-800 px-1 py-0.5 rounded">/storage/...</code> werden direkt abgespielt.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<strong class="font-medium text-zinc-800 dark:text-zinc-200">B2in Display</strong> –
|
<strong class="font-medium text-zinc-800 dark:text-zinc-200">B2in Display</strong> –
|
||||||
Für Medien-Rotation (Bilder und Videos) im Marken-Design. Inhalte: <em>Media-Items</em> mit Kategorie (Immobilien / Möbel), Überschrift, Unterzeile und Anzeigedauer. Unterstützt Light-/Dark-Theme.
|
Für Medien-Rotation (Bilder und Videos) im Marken-Design. Inhalte: <em>Media-Items</em> mit Kategorie (Immobilien / Möbel), Überschrift, Unterzeile und Anzeigedauer. Unterstützt Light-/Dark-Theme sowie zentrale Meta-Einstellungen für Header-Logo, Claim, Footer-Domain und QR-Code.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<strong class="font-medium text-zinc-800 dark:text-zinc-200">Angebote</strong> –
|
<strong class="font-medium text-zinc-800 dark:text-zinc-200">Angebote</strong> –
|
||||||
Für Produkt-Slides im Angebotsformat. Verschiedene Slide-Layouts: Intro, Produkt-Hero, Produkt-Details und Impuls-Slides mit Preisen, Badges und QR-Codes.
|
Für Produkt-Slides im Angebotsformat. Verschiedene Slide-Layouts: Intro, Produkt-Hero, Produkt-Details und Impuls-Slides mit Preisen, Badges und QR-Codes. Logo, Brand-Text, Footer-Claim und Web-/QR-URL werden einmal am Modul gepflegt und automatisch von allen Slides übernommen.
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p class="mt-2">
|
<p class="mt-2">
|
||||||
Innerhalb eines Moduls können Sie beliebig viele Inhalte anlegen, die Reihenfolge per Hoch/Runter-Sortierung festlegen und einzelne Einträge aktivieren oder deaktivieren.
|
Innerhalb eines Moduls können Sie beliebig viele Inhalte anlegen, die Reihenfolge per Hoch/Runter-Sortierung festlegen und einzelne Einträge aktivieren oder deaktivieren. Der Modul-Editor zeigt Inline-Vorschaubilder, eine 9:16-Player-Vorschau und eine Vollbild-Vorschau. Im Slide-Bearbeiten-Dialog wird nur der aktuell bearbeitete Slide als Einzel-Vorschau gerendert.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -238,9 +238,11 @@ $tabletStatus = computed(function () {
|
||||||
Ein <strong class="font-medium text-zinc-800 dark:text-zinc-200">Display</strong> repräsentiert einen physischen Bildschirm im Showroom.
|
Ein <strong class="font-medium text-zinc-800 dark:text-zinc-200">Display</strong> repräsentiert einen physischen Bildschirm im Showroom.
|
||||||
</p>
|
</p>
|
||||||
<ul class="mt-2 ml-5 list-disc space-y-1">
|
<ul class="mt-2 ml-5 list-disc space-y-1">
|
||||||
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Modul-Zuweisung:</strong> Jedem Display können Sie eine oder mehrere Module zuordnen. Die Module werden in der festgelegten Reihenfolge als Playlist abgespielt.</li>
|
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Live und Entwurf:</strong> Jedes Display zeigt den veröffentlichten Live-Stand und optional einen Entwurf. Entwürfe können aus Live angelegt, separat bearbeitet, verworfen oder veröffentlicht werden.</li>
|
||||||
|
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Modul-Zuweisung:</strong> Jedem Live- oder Entwurfsstand können Sie eine oder mehrere Module zuordnen. Die Module werden in der festgelegten Reihenfolge als Playlist abgespielt.</li>
|
||||||
|
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Vorschau:</strong> Live- und Entwurfs-URLs sind direkt kopierbar. Entwürfe und Module können zusätzlich im 9:16-Iframe oder im Vollbild geprüft werden.</li>
|
||||||
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Aktiv/Inaktiv:</strong> Über den Aktiv-Status können Sie einzelne Displays vorübergehend deaktivieren, ohne die Konfiguration zu verlieren.</li>
|
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Aktiv/Inaktiv:</strong> Über den Aktiv-Status können Sie einzelne Displays vorübergehend deaktivieren, ohne die Konfiguration zu verlieren.</li>
|
||||||
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">API-Anbindung:</strong> Jedes Display ruft seine Inhalte über eine JSON-API ab (<code class="text-xs bg-zinc-100 dark:bg-zinc-800 px-1 py-0.5 rounded">/api/display/{id}/config</code>). Änderungen werden beim nächsten Abruf automatisch übernommen.</li>
|
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">API-Anbindung:</strong> Jedes Display ruft seine Live-Inhalte über eine JSON-API ab (<code class="text-xs bg-zinc-100 dark:bg-zinc-800 px-1 py-0.5 rounded">/api/display/{id}/config</code>). Entwürfe laufen über Preview-Tokens, Module über eigene Preview-Endpunkte.</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -282,10 +284,12 @@ $tabletStatus = computed(function () {
|
||||||
Typischer Workflow
|
Typischer Workflow
|
||||||
</flux:heading>
|
</flux:heading>
|
||||||
<ol class="mt-2 ml-5 list-decimal space-y-1">
|
<ol class="mt-2 ml-5 list-decimal space-y-1">
|
||||||
|
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Medien hochladen</strong> – Bilder, SVG-Logos oder Videos in der Display-Mediathek ablegen.</li>
|
||||||
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Modul erstellen</strong> – Unter „Module" ein neues Modul mit passendem Typ anlegen (z. B. „Frühling 2026 Video").</li>
|
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Modul erstellen</strong> – Unter „Module" ein neues Modul mit passendem Typ anlegen (z. B. „Frühling 2026 Video").</li>
|
||||||
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Inhalte hinzufügen</strong> – Im Modul Videos, Medien oder Slides anlegen, Reihenfolge festlegen und aktivieren.</li>
|
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Meta-Einstellungen pflegen</strong> – Logo, Claim, Footer, QR-Code, Theme oder Anzeigezeiten einmal auf Modulebene setzen.</li>
|
||||||
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Display zuweisen</strong> – Unter „Displays" das Modul einem physischen Bildschirm zuordnen.</li>
|
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Inhalte hinzufügen</strong> – Im Modul Videos, Medien oder Slides anlegen, Reihenfolge festlegen, aktivieren und per Vorschau prüfen.</li>
|
||||||
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Fertig</strong> – Das Display lädt die neuen Inhalte automatisch über die API.</li>
|
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Display-Entwurf erstellen</strong> – Unter „Displays" aus dem Live-Stand einen Entwurf erzeugen und dort Module hinzufügen, sortieren oder entfernen.</li>
|
||||||
|
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Prüfen und veröffentlichen</strong> – Entwurf in der 9:16-Vorschau oder im Vollbild testen und anschließend bewusst veröffentlichen.</li>
|
||||||
</ol>
|
</ol>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,29 @@
|
||||||
</x-success-alert>
|
</x-success-alert>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
|
@php
|
||||||
|
$displayPlayerUrl = rtrim(config('display.player_url') ?: 'https://cabinet.b2in.eu/display', '/');
|
||||||
|
$displayOverviewUrl = $displayPlayerUrl.'/';
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<flux:card class="mb-6 border-blue-200 bg-blue-50/70 dark:border-blue-500/30 dark:bg-blue-950/20">
|
||||||
|
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<flux:heading size="lg">{{ __('Öffentliche Display-Übersicht') }}</flux:heading>
|
||||||
|
<flux:text class="mt-1">
|
||||||
|
{{ __('Hier sehen Sie alle aktiven Live-Displays und können die Wiedergabe direkt öffnen.') }}
|
||||||
|
</flux:text>
|
||||||
|
<div class="mt-2 text-xs font-mono text-blue-700 dark:text-blue-300">
|
||||||
|
{{ $displayOverviewUrl }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<flux:button href="{{ $displayOverviewUrl }}" target="_blank" variant="primary" icon="arrow-top-right-on-square">
|
||||||
|
{{ __('Display-Übersicht öffnen') }}
|
||||||
|
</flux:button>
|
||||||
|
</div>
|
||||||
|
</flux:card>
|
||||||
|
|
||||||
<flux:card>
|
<flux:card>
|
||||||
<div class="flex items-center justify-between mb-6">
|
<div class="flex items-center justify-between mb-6">
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -30,7 +53,7 @@
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
@foreach($displays as $display)
|
@foreach($displays as $display)
|
||||||
@php
|
@php
|
||||||
$liveDisplayUrl = url('/_cabinet/display/index.html').'?id='.$display->id;
|
$liveDisplayUrl = $displayPlayerUrl.'/?id='.$display->id;
|
||||||
$liveApiUrl = url('/api/display/'.$display->id.'/config');
|
$liveApiUrl = url('/api/display/'.$display->id.'/config');
|
||||||
@endphp
|
@endphp
|
||||||
<div wire:key="display-{{ $display->id }}"
|
<div wire:key="display-{{ $display->id }}"
|
||||||
|
|
@ -197,6 +220,13 @@
|
||||||
<flux:icon.play class="w-3 h-3" />
|
<flux:icon.play class="w-3 h-3" />
|
||||||
{{ __('Test-URL') }}
|
{{ __('Test-URL') }}
|
||||||
</a>
|
</a>
|
||||||
|
<flux:button wire:click="rotatePreviewToken({{ $display->id }})"
|
||||||
|
wire:confirm="Vorschau-Link neu erzeugen? Der bisherige Link wird damit ungültig."
|
||||||
|
size="xs"
|
||||||
|
variant="ghost"
|
||||||
|
icon="arrow-path">
|
||||||
|
{{ __('Link erneuern') }}
|
||||||
|
</flux:button>
|
||||||
<flux:button wire:click="publishDraft({{ $display->id }})"
|
<flux:button wire:click="publishDraft({{ $display->id }})"
|
||||||
wire:confirm="Diesen Entwurf veröffentlichen und den Live-Stand ersetzen?"
|
wire:confirm="Diesen Entwurf veröffentlichen und den Live-Stand ersetzen?"
|
||||||
size="xs"
|
size="xs"
|
||||||
|
|
@ -319,17 +349,23 @@
|
||||||
@if($availableVersions->isNotEmpty())
|
@if($availableVersions->isNotEmpty())
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<flux:select wire:model="addVersionSelect" placeholder="Modul hinzufügen...">
|
<flux:select wire:model="versionsToAdd"
|
||||||
|
variant="listbox"
|
||||||
|
multiple
|
||||||
|
searchable
|
||||||
|
placeholder="Module hinzufügen..."
|
||||||
|
selected-suffix="Module ausgewählt">
|
||||||
@foreach($availableVersions as $version)
|
@foreach($availableVersions as $version)
|
||||||
<option value="{{ $version->id }}">{{ $version->name }} ({{ $version->type->label() }})</option>
|
<flux:select.option value="{{ $version->id }}">{{ $version->name }} ({{ $version->type->label() }})</flux:select.option>
|
||||||
@endforeach
|
@endforeach
|
||||||
</flux:select>
|
</flux:select>
|
||||||
</div>
|
</div>
|
||||||
<flux:button wire:click="addVersion"
|
<flux:button wire:click="addSelectedVersions"
|
||||||
type="button"
|
type="button"
|
||||||
icon="plus"
|
icon="plus"
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost">
|
variant="ghost">
|
||||||
|
{{ __('Hinzufügen') }}
|
||||||
</flux:button>
|
</flux:button>
|
||||||
</div>
|
</div>
|
||||||
@else
|
@else
|
||||||
|
|
@ -364,6 +400,7 @@
|
||||||
wire:key="draft-preview-{{ $previewFrameRefreshCounter }}"
|
wire:key="draft-preview-{{ $previewFrameRefreshCounter }}"
|
||||||
src="{{ $draftPreviewUrl }}"
|
src="{{ $draftPreviewUrl }}"
|
||||||
class="h-full w-full border-0"
|
class="h-full w-full border-0"
|
||||||
|
loading="lazy"
|
||||||
title="{{ __('Entwurfs-Vorschau') }}"
|
title="{{ __('Entwurfs-Vorschau') }}"
|
||||||
></iframe>
|
></iframe>
|
||||||
@else
|
@else
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,12 @@ use Flux\Flux;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
|
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
|
||||||
use Livewire\WithFileUploads;
|
use Livewire\WithFileUploads;
|
||||||
|
use Livewire\WithPagination;
|
||||||
use function Livewire\Volt\{layout, title, state, computed, on, uses};
|
use function Livewire\Volt\{layout, title, state, computed, on, uses};
|
||||||
|
|
||||||
layout('components.layouts.app');
|
layout('components.layouts.app');
|
||||||
title('Display-Mediathek');
|
title('Display-Mediathek');
|
||||||
uses([WithFileUploads::class]);
|
uses([WithFileUploads::class, WithPagination::class]);
|
||||||
|
|
||||||
state([
|
state([
|
||||||
'search' => '',
|
'search' => '',
|
||||||
|
|
@ -47,12 +48,16 @@ $media = computed(
|
||||||
default => $q,
|
default => $q,
|
||||||
})
|
})
|
||||||
->when($this->filterCollection, fn ($q) => $q->inCollection($this->filterCollection))
|
->when($this->filterCollection, fn ($q) => $q->inCollection($this->filterCollection))
|
||||||
->when($this->search, fn ($q) => $q->where('filename', 'like', "%{$this->search}%")
|
->when($this->search, fn ($q) => $q->search($this->search))
|
||||||
->orWhere('title', 'like', "%{$this->search}%"))
|
|
||||||
->orderByDesc('created_at')
|
->orderByDesc('created_at')
|
||||||
->paginate(48),
|
->paginate(48),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$updatedSearch = fn () => $this->resetPage();
|
||||||
|
$updatedFilterType = fn () => $this->resetPage();
|
||||||
|
$updatedFilterSource = fn () => $this->resetPage();
|
||||||
|
$updatedFilterCollection = fn () => $this->resetPage();
|
||||||
|
|
||||||
$collections = computed(fn () => DisplayMedia::query()
|
$collections = computed(fn () => DisplayMedia::query()
|
||||||
->whereNotNull('collection')
|
->whereNotNull('collection')
|
||||||
->where('collection', '!=', '')
|
->where('collection', '!=', '')
|
||||||
|
|
@ -283,27 +288,37 @@ $closeDetail = function () {
|
||||||
class="group relative cursor-pointer overflow-hidden rounded-lg border transition-all
|
class="group relative cursor-pointer overflow-hidden rounded-lg border transition-all
|
||||||
{{ $editingId === $item->id ? 'border-blue-500 ring-2 ring-blue-200 dark:ring-blue-800' : 'border-zinc-200 hover:border-zinc-400 dark:border-zinc-700' }}"
|
{{ $editingId === $item->id ? 'border-blue-500 ring-2 ring-blue-200 dark:ring-blue-800' : 'border-zinc-200 hover:border-zinc-400 dark:border-zinc-700' }}"
|
||||||
wire:click="startEdit({{ $item->id }})">
|
wire:click="startEdit({{ $item->id }})">
|
||||||
<div class="aspect-square bg-zinc-100 dark:bg-zinc-800">
|
@php
|
||||||
@if ($item->isImage() && $item->isUpload())
|
$thumbSrc = $item->getThumbnailUrl() ?? ($item->isImage() && $item->isExternal() ? $item->external_url : null);
|
||||||
<img src="{{ $item->getThumbnailUrl() }}"
|
$videoFrameSrc = (! $thumbSrc && $item->isVideo() && $item->isUpload()) ? $item->getUrl() : null;
|
||||||
|
@endphp
|
||||||
|
<div class="relative aspect-square bg-zinc-100 dark:bg-zinc-800">
|
||||||
|
@if ($thumbSrc)
|
||||||
|
<img src="{{ $thumbSrc }}"
|
||||||
alt="{{ $item->alt_text ?? $item->filename }}"
|
alt="{{ $item->alt_text ?? $item->filename }}"
|
||||||
class="h-full w-full object-cover" loading="lazy" />
|
class="h-full w-full object-cover" loading="lazy" />
|
||||||
|
@elseif ($videoFrameSrc)
|
||||||
|
<video class="h-full w-full object-cover" preload="metadata" muted playsinline>
|
||||||
|
<source src="{{ $videoFrameSrc }}#t=1" type="{{ $item->mime_type }}">
|
||||||
|
</video>
|
||||||
@elseif ($item->isVideo())
|
@elseif ($item->isVideo())
|
||||||
<div class="flex h-full w-full flex-col items-center justify-center gap-2 text-purple-400">
|
<div class="flex h-full w-full flex-col items-center justify-center gap-2 text-purple-400">
|
||||||
<x-heroicon-o-film class="h-10 w-10" />
|
<x-heroicon-o-film class="h-10 w-10" />
|
||||||
<span class="text-xs">Video</span>
|
<span class="text-xs">Video</span>
|
||||||
</div>
|
</div>
|
||||||
@elseif ($item->isExternal() && $item->isImage())
|
|
||||||
<div class="flex h-full w-full flex-col items-center justify-center gap-2 text-blue-400">
|
|
||||||
<x-heroicon-o-photo class="h-10 w-10" />
|
|
||||||
<span class="text-xs">Extern</span>
|
|
||||||
</div>
|
|
||||||
@else
|
@else
|
||||||
<div class="flex h-full w-full flex-col items-center justify-center gap-2 text-zinc-400">
|
<div class="flex h-full w-full flex-col items-center justify-center gap-2 text-zinc-400">
|
||||||
<x-heroicon-o-link class="h-10 w-10" />
|
<x-heroicon-o-link class="h-10 w-10" />
|
||||||
<span class="text-xs">Link</span>
|
<span class="text-xs">Link</span>
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
@if ($item->isVideo() && ($thumbSrc || $videoFrameSrc))
|
||||||
|
<div class="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||||
|
<span class="flex h-10 w-10 items-center justify-center rounded-full bg-black/45 text-white ring-1 ring-white/30 backdrop-blur-sm">
|
||||||
|
<x-heroicon-s-play class="h-5 w-5" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-1.5 p-2">
|
<div class="flex items-center gap-1.5 p-2">
|
||||||
@if ($item->isVideo())
|
@if ($item->isVideo())
|
||||||
|
|
@ -352,9 +367,17 @@ $closeDetail = function () {
|
||||||
class="cursor-pointer transition {{ $editingId === $item->id ? 'bg-blue-50 dark:bg-blue-900/20' : 'hover:bg-zinc-50 dark:hover:bg-zinc-800/50' }}"
|
class="cursor-pointer transition {{ $editingId === $item->id ? 'bg-blue-50 dark:bg-blue-900/20' : 'hover:bg-zinc-50 dark:hover:bg-zinc-800/50' }}"
|
||||||
wire:click="startEdit({{ $item->id }})">
|
wire:click="startEdit({{ $item->id }})">
|
||||||
<td class="px-3 py-1.5">
|
<td class="px-3 py-1.5">
|
||||||
<div class="flex h-8 w-8 items-center justify-center overflow-hidden rounded border border-zinc-200 bg-zinc-100 dark:border-zinc-700 dark:bg-zinc-800">
|
@php
|
||||||
@if ($item->isImage() && $item->isUpload())
|
$rowThumb = $item->getThumbnailUrl() ?? ($item->isImage() && $item->isExternal() ? $item->external_url : null);
|
||||||
<img src="{{ $item->getThumbnailUrl() }}" class="h-full w-full object-cover" loading="lazy" />
|
$rowVideoFrame = (! $rowThumb && $item->isVideo() && $item->isUpload()) ? $item->getUrl() : null;
|
||||||
|
@endphp
|
||||||
|
<div class="relative flex h-8 w-8 items-center justify-center overflow-hidden rounded border border-zinc-200 bg-zinc-100 dark:border-zinc-700 dark:bg-zinc-800">
|
||||||
|
@if ($rowThumb)
|
||||||
|
<img src="{{ $rowThumb }}" class="h-full w-full object-cover" loading="lazy" />
|
||||||
|
@elseif ($rowVideoFrame)
|
||||||
|
<video class="h-full w-full object-cover" preload="metadata" muted playsinline>
|
||||||
|
<source src="{{ $rowVideoFrame }}#t=1" type="{{ $item->mime_type }}">
|
||||||
|
</video>
|
||||||
@elseif ($item->isVideo())
|
@elseif ($item->isVideo())
|
||||||
<x-heroicon-s-film class="h-4 w-4 text-purple-500" />
|
<x-heroicon-s-film class="h-4 w-4 text-purple-500" />
|
||||||
@else
|
@else
|
||||||
|
|
@ -418,17 +441,18 @@ $closeDetail = function () {
|
||||||
|
|
||||||
{{-- Preview --}}
|
{{-- Preview --}}
|
||||||
<div class="mb-4 overflow-hidden rounded-lg border border-zinc-200 bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-800">
|
<div class="mb-4 overflow-hidden rounded-lg border border-zinc-200 bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-800">
|
||||||
@if ($editMedia->isImage() && $editMedia->isUpload())
|
@if ($editMedia->isImage())
|
||||||
<img src="{{ $editMedia->getUrl() }}" alt="{{ $editMedia->filename }}"
|
<img src="{{ $editMedia->getUrl() }}" alt="{{ $editMedia->filename }}"
|
||||||
class="w-full object-contain" style="max-height: 300px;" />
|
class="w-full object-contain" style="max-height: 300px;" />
|
||||||
@elseif ($editMedia->isVideo() && $editMedia->isUpload())
|
@elseif ($editMedia->isVideo() && $editMedia->isUpload())
|
||||||
<video controls class="w-full" style="max-height: 300px;">
|
<video controls preload="metadata" class="w-full" style="max-height: 300px;"
|
||||||
|
@if ($editMedia->getThumbnailUrl()) poster="{{ $editMedia->getThumbnailUrl() }}" @endif>
|
||||||
<source src="{{ $editMedia->getUrl() }}" type="{{ $editMedia->mime_type }}">
|
<source src="{{ $editMedia->getUrl() }}" type="{{ $editMedia->mime_type }}">
|
||||||
</video>
|
</video>
|
||||||
@elseif ($editMedia->isExternal())
|
@elseif ($editMedia->isExternal())
|
||||||
<div class="flex flex-col items-center justify-center gap-3 py-8">
|
<div class="flex flex-col items-center justify-center gap-3 py-8">
|
||||||
<x-heroicon-o-link class="h-12 w-12 text-blue-400" />
|
<x-heroicon-o-link class="h-12 w-12 text-blue-400" />
|
||||||
<span class="text-sm text-zinc-500">Externe Ressource</span>
|
<span class="text-sm text-zinc-500">Externe Ressource (Vorschau nicht einbettbar)</span>
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,20 @@
|
||||||
<div class="flex items-end gap-3">
|
<div class="flex items-end gap-3">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
@if ($selectedMedia)
|
@if ($selectedMedia)
|
||||||
|
@php
|
||||||
|
$selectedThumb = $selectedMedia->getThumbnailUrl()
|
||||||
|
?? ($selectedMedia->isImage() && $selectedMedia->isExternal() ? $selectedMedia->external_url : null);
|
||||||
|
$selectedVideoFrame = (! $selectedThumb && $selectedMedia->isVideo() && $selectedMedia->isUpload()) ? $selectedMedia->getUrl() : null;
|
||||||
|
@endphp
|
||||||
<div class="flex items-center gap-3 rounded-lg border border-zinc-200 p-2 dark:border-zinc-700">
|
<div class="flex items-center gap-3 rounded-lg border border-zinc-200 p-2 dark:border-zinc-700">
|
||||||
@if ($selectedMedia->isImage() && $selectedMedia->isUpload())
|
@if ($selectedThumb)
|
||||||
<img src="{{ $selectedMedia->getThumbnailUrl() }}"
|
<img src="{{ $selectedThumb }}"
|
||||||
alt="{{ $selectedMedia->filename }}"
|
alt="{{ $selectedMedia->filename }}"
|
||||||
class="h-16 w-16 rounded-md object-cover" />
|
class="h-16 w-16 rounded-md object-cover" />
|
||||||
|
@elseif ($selectedVideoFrame)
|
||||||
|
<video class="h-16 w-16 rounded-md object-cover" preload="metadata" muted playsinline>
|
||||||
|
<source src="{{ $selectedVideoFrame }}#t=1" type="{{ $selectedMedia->mime_type }}">
|
||||||
|
</video>
|
||||||
@elseif ($selectedMedia->isVideo())
|
@elseif ($selectedMedia->isVideo())
|
||||||
<div class="flex h-16 w-16 items-center justify-center rounded-md bg-purple-50 dark:bg-purple-900/20">
|
<div class="flex h-16 w-16 items-center justify-center rounded-md bg-purple-50 dark:bg-purple-900/20">
|
||||||
<x-heroicon-o-film class="h-8 w-8 text-purple-500" />
|
<x-heroicon-o-film class="h-8 w-8 text-purple-500" />
|
||||||
|
|
@ -88,9 +97,18 @@
|
||||||
{{ $value === $item->id ? 'border-blue-500 ring-2 ring-blue-200' : 'border-zinc-200 hover:border-blue-300 dark:border-zinc-700' }}"
|
{{ $value === $item->id ? 'border-blue-500 ring-2 ring-blue-200' : 'border-zinc-200 hover:border-blue-300 dark:border-zinc-700' }}"
|
||||||
wire:click="selectMedia({{ $item->id }})">
|
wire:click="selectMedia({{ $item->id }})">
|
||||||
<div class="relative aspect-square bg-zinc-100 dark:bg-zinc-800">
|
<div class="relative aspect-square bg-zinc-100 dark:bg-zinc-800">
|
||||||
@if ($item->isImage() && $item->isUpload())
|
@php
|
||||||
<img src="{{ $item->getThumbnailUrl() }}"
|
$pickThumb = $item->getThumbnailUrl()
|
||||||
|
?? ($item->isImage() && $item->isExternal() ? $item->external_url : null);
|
||||||
|
$pickVideoFrame = (! $pickThumb && $item->isVideo() && $item->isUpload()) ? $item->getUrl() : null;
|
||||||
|
@endphp
|
||||||
|
@if ($pickThumb)
|
||||||
|
<img src="{{ $pickThumb }}"
|
||||||
alt="{{ $item->filename }}" class="h-full w-full object-cover" loading="lazy" />
|
alt="{{ $item->filename }}" class="h-full w-full object-cover" loading="lazy" />
|
||||||
|
@elseif ($pickVideoFrame)
|
||||||
|
<video class="h-full w-full object-cover" preload="metadata" muted playsinline>
|
||||||
|
<source src="{{ $pickVideoFrame }}#t=1" type="{{ $item->mime_type }}">
|
||||||
|
</video>
|
||||||
@elseif ($item->isVideo())
|
@elseif ($item->isVideo())
|
||||||
<div class="flex h-full w-full items-center justify-center text-purple-500">
|
<div class="flex h-full w-full items-center justify-center text-purple-500">
|
||||||
<x-heroicon-o-film class="h-10 w-10" />
|
<x-heroicon-o-film class="h-10 w-10" />
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,7 @@
|
||||||
wire:key="module-preview-{{ $previewFrameRefreshCounter }}"
|
wire:key="module-preview-{{ $previewFrameRefreshCounter }}"
|
||||||
src="{{ $this->modulePreviewUrl() }}"
|
src="{{ $this->modulePreviewUrl() }}"
|
||||||
class="h-full w-full border-0"
|
class="h-full w-full border-0"
|
||||||
|
loading="lazy"
|
||||||
title="{{ __('Modul-Vorschau') }}"
|
title="{{ __('Modul-Vorschau') }}"
|
||||||
></iframe>
|
></iframe>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -120,9 +121,13 @@
|
||||||
type="video"
|
type="video"
|
||||||
label="Video aus Mediathek"
|
label="Video aus Mediathek"
|
||||||
:key="'picker-video-' . ($itemId ?? 'new')" />
|
:key="'picker-video-' . ($itemId ?? 'new')" />
|
||||||
<flux:input wire:model="videoFilename" label="Video-Pfad / URL" placeholder="Wird automatisch gesetzt oder manuell eingeben..."
|
<div class="flex items-end gap-3">
|
||||||
description="Über die Mediathek auswählen oder direkt Pfad/URL eingeben." />
|
<x-media-thumb :url="$videoFilename" />
|
||||||
<flux:input wire:model="videoTitle" label="Titel (optional)" placeholder="z.B. Herbst Kollektion 2025" />
|
<flux:input wire:model.live.debounce.500ms="videoFilename" label="Video-Pfad / URL" placeholder="Wird automatisch gesetzt oder manuell eingeben..."
|
||||||
|
description="Über die Mediathek auswählen oder direkt Pfad/URL eingeben." class="flex-1" />
|
||||||
|
</div>
|
||||||
|
<flux:input wire:model="videoTitle" label="Name" placeholder="z.B. Herbst Kollektion 2025"
|
||||||
|
description="Interner Name des Videos – wird nicht im Video eingeblendet." />
|
||||||
<flux:input wire:model="videoPosition" type="number" min="0" max="100" label="Position (%)"
|
<flux:input wire:model="videoPosition" type="number" min="0" max="100" label="Position (%)"
|
||||||
description="Vertikale Position im Video (0 = oben, 100 = unten)" />
|
description="Vertikale Position im Video (0 = oben, 100 = unten)" />
|
||||||
<flux:checkbox wire:model="videoIsActive" label="Aktiv" />
|
<flux:checkbox wire:model="videoIsActive" label="Aktiv" />
|
||||||
|
|
@ -139,22 +144,29 @@
|
||||||
|
|
||||||
{{-- Media fields (B2in) --}}
|
{{-- Media fields (B2in) --}}
|
||||||
@if($itemType === 'media')
|
@if($itemType === 'media')
|
||||||
<flux:select wire:model="mediaType" label="Medientyp">
|
|
||||||
<option value="image">Bild</option>
|
|
||||||
<option value="video">Video</option>
|
|
||||||
</flux:select>
|
|
||||||
<flux:select wire:model="mediaCategory" label="Kategorie">
|
|
||||||
<option value="immobilien">Immobilien</option>
|
|
||||||
<option value="moebel">Möbel</option>
|
|
||||||
</flux:select>
|
|
||||||
<livewire:admin.cms.display-media-picker
|
<livewire:admin.cms.display-media-picker
|
||||||
:value="null"
|
:value="null"
|
||||||
field="mediaUrl"
|
field="mediaUrl"
|
||||||
:type="$mediaType === 'video' ? 'video' : 'image'"
|
type="all"
|
||||||
label="Aus Mediathek"
|
label="Aus Mediathek"
|
||||||
:key="'picker-media-' . ($itemId ?? 'new')" />
|
:key="'picker-media-' . ($itemId ?? 'new')" />
|
||||||
<flux:input wire:model="mediaUrl" label="Medien-URL" placeholder="Wird automatisch gesetzt oder manuell eingeben..."
|
<div class="flex items-end gap-3">
|
||||||
description="Über die Mediathek auswählen oder direkt URL eingeben." />
|
<x-media-thumb :url="$mediaUrl" />
|
||||||
|
<flux:input wire:model.live.debounce.500ms="mediaUrl" label="Medien-URL" placeholder="Wird automatisch gesetzt oder manuell eingeben..."
|
||||||
|
description="Über die Mediathek auswählen oder direkt URL eingeben." class="flex-1" />
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<flux:text class="text-sm">{{ __('Medientyp:') }}</flux:text>
|
||||||
|
<flux:badge size="sm" :color="$mediaType === 'video' ? 'purple' : 'sky'">
|
||||||
|
{{ $mediaType === 'video' ? __('Video') : __('Bild') }}
|
||||||
|
</flux:badge>
|
||||||
|
<flux:text class="text-xs text-zinc-400">{{ __('wird automatisch aus dem gewählten Medium erkannt') }}</flux:text>
|
||||||
|
</div>
|
||||||
|
<flux:select wire:model="mediaCategory" label="Kategorie">
|
||||||
|
<option value="immobilien">Immobilien</option>
|
||||||
|
<option value="moebel">Möbel</option>
|
||||||
|
<option value="sonstiges">Sonstiges</option>
|
||||||
|
</flux:select>
|
||||||
<flux:input wire:model="mediaHeadline" label="Überschrift" placeholder="z.B. Ihr Zuhause. Weltweit." />
|
<flux:input wire:model="mediaHeadline" label="Überschrift" placeholder="z.B. Ihr Zuhause. Weltweit." />
|
||||||
<flux:input wire:model="mediaSubline" label="Unterzeile" placeholder="z.B. Beratung und Vermittlung." />
|
<flux:input wire:model="mediaSubline" label="Unterzeile" placeholder="z.B. Beratung und Vermittlung." />
|
||||||
@if($mediaType === 'image')
|
@if($mediaType === 'image')
|
||||||
|
|
@ -164,47 +176,79 @@
|
||||||
<flux:checkbox wire:model="mediaIsActive" label="Aktiv" />
|
<flux:checkbox wire:model="mediaIsActive" label="Aktiv" />
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
{{-- Slide fields (Offers) --}}
|
{{-- Slide fields (Offers) – einheitliches Detail-Layout mit Ein-/Ausblende-Schaltern --}}
|
||||||
@if($itemType === 'slide')
|
@if($itemType === 'slide')
|
||||||
{{-- Basis --}}
|
<flux:text class="text-sm text-zinc-500 dark:text-zinc-400">
|
||||||
<flux:select wire:model.live="slideType" label="Slide-Typ">
|
{{ __('Jedes Angebot nutzt dasselbe Detail-Layout. Über die Schalter blendest du einzelne Bausteine ein oder aus und befüllst sie mit Inhalten.') }}
|
||||||
<option value="intro">Intro</option>
|
</flux:text>
|
||||||
<option value="product-hero">Produkt-Hero</option>
|
|
||||||
<option value="product-details">Produkt-Details</option>
|
|
||||||
<option value="product-impulse">Produkt-Impuls</option>
|
|
||||||
</flux:select>
|
|
||||||
<flux:input wire:model="slideDuration" type="number" min="1000" label="Dauer (ms)" />
|
|
||||||
<livewire:admin.cms.display-media-picker
|
|
||||||
:value="null"
|
|
||||||
field="slideImageUrl"
|
|
||||||
type="image"
|
|
||||||
label="Bild aus Mediathek"
|
|
||||||
:key="'picker-slide-' . ($itemId ?? 'new')" />
|
|
||||||
<flux:input wire:model="slideImageUrl" label="Bild-URL" placeholder="Wird automatisch gesetzt oder manuell eingeben..."
|
|
||||||
description="Über die Mediathek auswählen oder direkt URL eingeben." />
|
|
||||||
<flux:input wire:model="slideBadge" label="Badge-Text" placeholder="z.B. Einzelstück" />
|
|
||||||
<flux:input wire:model="slideEyebrow" label="Eyebrow" placeholder="z.B. Hersteller: Sudbrock" />
|
|
||||||
<flux:input wire:model="slideTitle" label="Titel" placeholder="z.B. GOYA Sideboard" />
|
|
||||||
|
|
||||||
{{-- Intro-spezifisch --}}
|
{{-- Logo & Marke (Kopfbereich) --}}
|
||||||
@if($slideType === 'intro')
|
<div class="space-y-4 rounded-xl border border-zinc-200 p-4 dark:border-zinc-700">
|
||||||
<flux:input wire:model="slideDisclaimer" label="Disclaimer" placeholder="z.B. Zwischenverkauf vorbehalten" />
|
<flux:heading size="sm">{{ __('Logo & Marke') }}</flux:heading>
|
||||||
<flux:checkbox wire:model="slideShowBrandText" label="Brand-Text anzeigen" />
|
<flux:switch wire:model.live="slideShowLogo" label="Logo & Marken-Text anzeigen"
|
||||||
@if($slideShowBrandText)
|
description="Kopfbereich oben mit Logo, Marken-Text und Tagline." />
|
||||||
<flux:input wire:model="slideBrandTagline" label="Brand-Tagline" placeholder="z.B. Planung • Beratung • Lieferung & Montage" />
|
@if($slideShowLogo)
|
||||||
|
<livewire:admin.cms.display-media-picker
|
||||||
|
:value="null"
|
||||||
|
field="slideLogoUrl"
|
||||||
|
type="image"
|
||||||
|
label="Logo aus Mediathek"
|
||||||
|
:key="'picker-slide-logo-' . ($itemId ?? 'new')" />
|
||||||
|
<div class="flex items-end gap-3">
|
||||||
|
<x-media-thumb :url="$slideLogoUrl" />
|
||||||
|
<flux:input wire:model.live.debounce.500ms="slideLogoUrl" label="Logo-URL" placeholder="Standard: CABINET-Logo"
|
||||||
|
description="Leer = Standard-Logo wird genutzt." class="flex-1" />
|
||||||
|
</div>
|
||||||
|
<flux:input wire:model="slideBrandText" label="Marken-Text" placeholder="z.B. Bielefeld"
|
||||||
|
description="Text direkt neben dem Logo (optional)." />
|
||||||
|
<flux:input wire:model="slideBrandTagline" label="Tagline" placeholder="z.B. Planung • Beratung • Lieferung & Montage"
|
||||||
|
description="Rechts im Kopfbereich (optional)." />
|
||||||
@endif
|
@endif
|
||||||
@endif
|
</div>
|
||||||
|
|
||||||
{{-- Product-Hero --}}
|
{{-- Bild & Badge (wichtigstes Element – farblich hervorgehoben) --}}
|
||||||
@if($slideType === 'product-hero')
|
<div class="space-y-4 rounded-xl border-2 border-blue-300 bg-blue-50/60 p-4 ring-1 ring-blue-200 dark:border-blue-700 dark:bg-blue-950/30 dark:ring-blue-900">
|
||||||
<flux:input wire:model="slidePrice" label="Preis" placeholder="z.B. 489 €" />
|
<div class="flex items-center gap-2">
|
||||||
<flux:input wire:model="slideOriginalPrice" label="Originalpreis" placeholder="z.B. statt 4.744 €" />
|
<flux:heading size="sm">{{ __('Bild & Badge') }}</flux:heading>
|
||||||
@endif
|
<flux:badge color="blue" size="sm">{{ __('Wichtigstes Element') }}</flux:badge>
|
||||||
|
</div>
|
||||||
|
<livewire:admin.cms.display-media-picker
|
||||||
|
:value="null"
|
||||||
|
field="slideImageUrl"
|
||||||
|
type="image"
|
||||||
|
label="Bild aus Mediathek"
|
||||||
|
:key="'picker-slide-' . ($itemId ?? 'new')" />
|
||||||
|
<div class="flex items-end gap-3">
|
||||||
|
<x-media-thumb :url="$slideImageUrl" />
|
||||||
|
<flux:input wire:model.live.debounce.500ms="slideImageUrl" label="Bild-URL" placeholder="Wird automatisch gesetzt oder manuell eingeben..."
|
||||||
|
description="Über die Mediathek auswählen oder direkt URL eingeben." class="flex-1" />
|
||||||
|
</div>
|
||||||
|
<flux:switch wire:model.live="slideShowBadge" label="Badge anzeigen" description="Kleine Markierung über dem Bild." />
|
||||||
|
@if($slideShowBadge)
|
||||||
|
<flux:input wire:model="slideBadge" label="Badge-Text" placeholder="z.B. Einzelstück" />
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
{{-- Product-Details --}}
|
{{-- Texte --}}
|
||||||
@if($slideType === 'product-details')
|
<div class="space-y-4 rounded-xl border border-zinc-200 p-4 dark:border-zinc-700">
|
||||||
<div>
|
<flux:heading size="sm">{{ __('Texte') }}</flux:heading>
|
||||||
<flux:heading size="sm" class="mb-2">{{ __('Aufzählungspunkte') }}</flux:heading>
|
<flux:input wire:model="slideTitle" label="Titel" placeholder="z.B. GOYA Sideboard"
|
||||||
|
description="Hauptüberschrift des Angebots (Zeilenumbruch mit Enter möglich)." />
|
||||||
|
<flux:switch wire:model.live="slideShowEyebrow" label="Eyebrow anzeigen" description="Kleine Überzeile über dem Titel." />
|
||||||
|
@if($slideShowEyebrow)
|
||||||
|
<flux:input wire:model="slideEyebrow" label="Eyebrow" placeholder="z.B. Hersteller: Sudbrock" />
|
||||||
|
@endif
|
||||||
|
<flux:switch wire:model.live="slideShowSubline" label="Unterzeile anzeigen" />
|
||||||
|
@if($slideShowSubline)
|
||||||
|
<flux:input wire:model="slideSubline" label="Unterzeile" placeholder="z.B. Heute mitnehmen" />
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Aufzählung --}}
|
||||||
|
<div class="space-y-4 rounded-xl border border-zinc-200 p-4 dark:border-zinc-700">
|
||||||
|
<flux:heading size="sm">{{ __('Aufzählungspunkte') }}</flux:heading>
|
||||||
|
<flux:switch wire:model.live="slideShowBullets" label="Aufzählung anzeigen" description="Liste mit Stichpunkten, z.B. Produktdetails." />
|
||||||
|
@if($slideShowBullets)
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
@foreach($slideBullets as $i => $bullet)
|
@foreach($slideBullets as $i => $bullet)
|
||||||
<div class="flex items-center gap-2" wire:key="bullet-{{ $i }}">
|
<div class="flex items-center gap-2" wire:key="bullet-{{ $i }}">
|
||||||
|
|
@ -213,30 +257,58 @@
|
||||||
</div>
|
</div>
|
||||||
@endforeach
|
@endforeach
|
||||||
</div>
|
</div>
|
||||||
<flux:button wire:click="addBullet" size="xs" variant="ghost" icon="plus" class="mt-2">
|
<flux:button wire:click="addBullet" size="xs" variant="ghost" icon="plus">
|
||||||
{{ __('Punkt hinzufügen') }}
|
{{ __('Punkt hinzufügen') }}
|
||||||
</flux:button>
|
</flux:button>
|
||||||
</div>
|
@endif
|
||||||
@endif
|
|
||||||
|
|
||||||
{{-- Product-Impulse --}}
|
|
||||||
@if($slideType === 'product-impulse')
|
|
||||||
<flux:input wire:model="slideSubline" label="Subline" placeholder="z.B. Heute mitnehmen" />
|
|
||||||
<flux:input wire:model="slidePrice" label="Preis" placeholder="z.B. 199 €" />
|
|
||||||
<flux:input wire:model="slideTagText" label="Tag-Text" placeholder="z.B. Im Store verfügbar" />
|
|
||||||
@endif
|
|
||||||
|
|
||||||
{{-- QR --}}
|
|
||||||
<div class="border-t border-zinc-200 dark:border-zinc-700 pt-4 mt-2">
|
|
||||||
<flux:heading size="sm" class="mb-3">{{ __('QR-Code & Kontakt') }}</flux:heading>
|
|
||||||
<div class="space-y-4">
|
|
||||||
<flux:input wire:model="slideQrUrl" label="QR-URL" placeholder="z.B. https://cabinet-bielefeld.de" />
|
|
||||||
<flux:input wire:model="slideQrTitle" label="QR-Titel" placeholder="z.B. Reservieren" />
|
|
||||||
<flux:input wire:model="slideContact" label="Kontakt" placeholder="z.B. 0521 98620100 / Tel. oder WhatsApp" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<flux:checkbox wire:model="slideIsActive" label="Aktiv" />
|
{{-- Preis --}}
|
||||||
|
<div class="space-y-4 rounded-xl border border-zinc-200 p-4 dark:border-zinc-700">
|
||||||
|
<flux:heading size="sm">{{ __('Preis') }}</flux:heading>
|
||||||
|
<flux:switch wire:model.live="slideShowPrice" label="Preis anzeigen" />
|
||||||
|
@if($slideShowPrice)
|
||||||
|
<flux:input wire:model="slidePrice" label="Preis" placeholder="z.B. 489 €" />
|
||||||
|
<flux:input wire:model="slideOriginalPrice" label="Originalpreis (optional)" placeholder="z.B. statt 4.744 €" />
|
||||||
|
@if(trim($slideOriginalPrice) !== '')
|
||||||
|
<flux:switch wire:model.live="slideStrikeOriginalPrice" label="Streichpreis"
|
||||||
|
description="Originalpreis wird rot durchgestrichen dargestellt." />
|
||||||
|
@endif
|
||||||
|
<flux:input wire:model="slideTagText" label="Hinweis-Tag (optional)" placeholder="z.B. Im Store verfügbar" />
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Hinweis --}}
|
||||||
|
<div class="space-y-4 rounded-xl border border-zinc-200 p-4 dark:border-zinc-700">
|
||||||
|
<flux:heading size="sm">{{ __('Hinweis') }}</flux:heading>
|
||||||
|
<flux:switch wire:model.live="slideShowDisclaimer" label="Disclaimer anzeigen" />
|
||||||
|
@if($slideShowDisclaimer)
|
||||||
|
<flux:input wire:model="slideDisclaimer" label="Disclaimer" placeholder="z.B. Zwischenverkauf vorbehalten" />
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- QR & Kontakt --}}
|
||||||
|
<div class="space-y-4 rounded-xl border border-zinc-200 p-4 dark:border-zinc-700">
|
||||||
|
<flux:heading size="sm">{{ __('QR-Code & Kontakt') }}</flux:heading>
|
||||||
|
<flux:switch wire:model.live="slideShowQr" label="QR-Code anzeigen" />
|
||||||
|
@if($slideShowQr)
|
||||||
|
<flux:input wire:model="slideQrUrl" label="QR-URL" placeholder="z.B. https://cabinet-bielefeld.de"
|
||||||
|
description="Leer = es wird die Web/QR-URL aus den Einstellungen genutzt." />
|
||||||
|
<flux:input wire:model="slideQrTitle" label="QR-Titel" placeholder="z.B. Reservieren" />
|
||||||
|
@endif
|
||||||
|
<flux:switch wire:model.live="slideShowContact" label="Kontakt anzeigen" />
|
||||||
|
@if($slideShowContact)
|
||||||
|
<flux:input wire:model="slideContact" label="Kontakt" placeholder="z.B. 0521 98620100 / Tel. oder WhatsApp" />
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Anzeige --}}
|
||||||
|
<div class="space-y-4 rounded-xl border border-zinc-200 p-4 dark:border-zinc-700">
|
||||||
|
<flux:heading size="sm">{{ __('Anzeige') }}</flux:heading>
|
||||||
|
<flux:input wire:model="slideDuration" type="number" min="1000" step="500" label="Dauer (ms)"
|
||||||
|
description="Wie lange dieses Angebot eingeblendet wird." />
|
||||||
|
<flux:switch wire:model="slideIsActive" label="Aktiv" description="Inaktive Angebote werden im Display übersprungen." />
|
||||||
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
<div class="space-y-3 border-t border-zinc-200 pt-6 dark:border-zinc-700">
|
<div class="space-y-3 border-t border-zinc-200 pt-6 dark:border-zinc-700">
|
||||||
|
|
@ -245,21 +317,37 @@
|
||||||
<flux:subheading>{{ __('Zeigt nur den aktuell bearbeiteten Inhalt im Display-Player') }}</flux:subheading>
|
<flux:subheading>{{ __('Zeigt nur den aktuell bearbeiteten Inhalt im Display-Player') }}</flux:subheading>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mx-auto aspect-[9/16] w-full max-w-[320px] overflow-hidden rounded-xl border border-zinc-200 bg-black shadow-sm dark:border-zinc-700">
|
{{-- Stable iframe element: only its `src` changes between the
|
||||||
|
"new" (about:blank) and "saved" state. Swapping the element
|
||||||
|
structure inside the teleported Flux modal crashes Livewire's
|
||||||
|
morph/cleanup, so we keep the DOM shape constant. --}}
|
||||||
|
<div class="relative mx-auto aspect-[9/16] w-full max-w-[320px] overflow-hidden rounded-xl border border-zinc-200 bg-black shadow-sm dark:border-zinc-700">
|
||||||
<iframe
|
<iframe
|
||||||
wire:key="item-modal-module-preview-{{ $previewFrameRefreshCounter }}"
|
wire:key="item-modal-preview"
|
||||||
src="{{ $this->itemPreviewUrl() }}"
|
src="{{ $itemId ? $this->itemPreviewUrl() : 'about:blank' }}"
|
||||||
class="h-full w-full border-0"
|
class="h-full w-full border-0"
|
||||||
|
loading="lazy"
|
||||||
title="{{ __('Einzel-Vorschau im Bearbeiten-Dialog') }}"
|
title="{{ __('Einzel-Vorschau im Bearbeiten-Dialog') }}"
|
||||||
></iframe>
|
></iframe>
|
||||||
|
|
||||||
|
@unless($itemId)
|
||||||
|
<div class="absolute inset-0 flex items-center justify-center bg-zinc-50 text-center dark:bg-zinc-900">
|
||||||
|
<div class="px-6 text-sm text-zinc-500 dark:text-zinc-400">
|
||||||
|
<flux:icon.eye class="mx-auto mb-2 h-8 w-8 opacity-40" />
|
||||||
|
{{ __('Die Vorschau erscheint, sobald der Inhalt gespeichert wurde.') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endunless
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<a href="{{ $this->itemPreviewUrl() }}"
|
@if($itemId)
|
||||||
target="_blank"
|
<a href="{{ $this->itemPreviewUrl() }}"
|
||||||
class="mx-auto flex max-w-[320px] items-center justify-center gap-1 text-xs text-blue-600 hover:underline dark:text-blue-400">
|
target="_blank"
|
||||||
<flux:icon.arrow-top-right-on-square class="w-3 h-3" />
|
class="mx-auto flex max-w-[320px] items-center justify-center gap-1 text-xs text-blue-600 hover:underline dark:text-blue-400">
|
||||||
{{ __('Vollbild öffnen') }}
|
<flux:icon.arrow-top-right-on-square class="w-3 h-3" />
|
||||||
</a>
|
{{ __('Vollbild öffnen') }}
|
||||||
|
</a>
|
||||||
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-wrap justify-end gap-3 border-t border-zinc-200 pt-4 dark:border-zinc-700">
|
<div class="flex flex-wrap justify-end gap-3 border-t border-zinc-200 pt-4 dark:border-zinc-700">
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,15 @@
|
||||||
</x-success-alert>
|
</x-success-alert>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
|
@if (session()->has('error'))
|
||||||
|
<div class="mb-4 rounded-lg border border-red-200 bg-red-50 p-4 dark:border-red-800 dark:bg-red-900/20">
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<flux:icon.exclamation-circle class="mt-0.5 h-5 w-5 shrink-0 text-red-600 dark:text-red-400" />
|
||||||
|
<p class="text-sm font-medium text-red-800 dark:text-red-200">{{ session('error') }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
<flux:card>
|
<flux:card>
|
||||||
<div class="flex items-center justify-between mb-6">
|
<div class="flex items-center justify-between mb-6">
|
||||||
<div>
|
<div>
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
accept="image/jpeg,image/png,image/gif,image/webp,image/svg+xml,.pdf,.doc,.docx,.jpg,.jpeg,.png">
|
accept="image/jpeg,image/png,image/gif,image/webp,image/svg+xml,.pdf,.doc,.docx,.jpg,.jpeg,.png">
|
||||||
<flux:file-upload.dropzone
|
<flux:file-upload.dropzone
|
||||||
heading="Dateien hochladen"
|
heading="Dateien hochladen"
|
||||||
text="Bilder (JPG, PNG, WebP, SVG) und Dokumente (PDF, DOC) — max. 10 MB pro Datei"
|
text="Bilder (JPG, PNG, WebP, SVG) und Dokumente (PDF, DOC) — max. 200 MB pro Datei"
|
||||||
with-progress />
|
with-progress />
|
||||||
</flux:file-upload>
|
</flux:file-upload>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,18 +6,18 @@
|
||||||
<flux:card>
|
<flux:card>
|
||||||
<div class="flex items-center justify-between mb-6">
|
<div class="flex items-center justify-between mb-6">
|
||||||
<div>
|
<div>
|
||||||
<flux:heading size="lg">{{ __('Slides') }}</flux:heading>
|
<flux:heading size="lg">{{ __('Angebote') }}</flux:heading>
|
||||||
<flux:subheading>{{ __('Angebots-Slides werden in der angegebenen Reihenfolge angezeigt') }}</flux:subheading>
|
<flux:subheading>{{ __('Angebote werden im einheitlichen Detail-Layout in der angegebenen Reihenfolge angezeigt') }}</flux:subheading>
|
||||||
</div>
|
</div>
|
||||||
<flux:button wire:click="openItemModal(null, 'slide')" icon="plus">
|
<flux:button wire:click="openItemModal(null, 'slide')" icon="plus">
|
||||||
{{ __('Slide hinzufügen') }}
|
{{ __('Angebot hinzufügen') }}
|
||||||
</flux:button>
|
</flux:button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if($slides->isEmpty())
|
@if($slides->isEmpty())
|
||||||
<div class="text-center py-12 text-zinc-500 dark:text-zinc-400">
|
<div class="text-center py-12 text-zinc-500 dark:text-zinc-400">
|
||||||
<flux:icon.presentation-chart-bar class="w-16 h-16 mx-auto mb-4 opacity-50" />
|
<flux:icon.presentation-chart-bar class="w-16 h-16 mx-auto mb-4 opacity-50" />
|
||||||
<p>{{ __('Noch keine Slides vorhanden.') }}</p>
|
<p>{{ __('Noch keine Angebote vorhanden.') }}</p>
|
||||||
</div>
|
</div>
|
||||||
@else
|
@else
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
|
|
@ -42,29 +42,30 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
|
@php
|
||||||
|
$c = $item->content;
|
||||||
|
$enabledBlocks = collect([
|
||||||
|
'Badge' => ($c['show_badge'] ?? ! empty($c['badge_text'])) && ! empty($c['badge_text']),
|
||||||
|
'Aufzählung' => ($c['show_bullets'] ?? ! empty($c['bullets'])) && ! empty($c['bullets']),
|
||||||
|
'Preis' => ($c['show_price'] ?? ! empty($c['price'])) && ! empty($c['price']),
|
||||||
|
'QR' => ($c['show_qr'] ?? ! empty($c['qr_url'])) && ! empty($c['qr_url']),
|
||||||
|
'Kontakt' => ($c['show_contact'] ?? ! empty($c['contact'])) && ! empty($c['contact']),
|
||||||
|
])->filter()->keys();
|
||||||
|
@endphp
|
||||||
<div class="flex items-center gap-3 mb-1">
|
<div class="flex items-center gap-3 mb-1">
|
||||||
<flux:badge :color="$item->is_active ? 'green' : 'zinc'" size="sm">
|
<flux:badge :color="$item->is_active ? 'green' : 'zinc'" size="sm">
|
||||||
{{ $item->is_active ? __('Aktiv') : __('Inaktiv') }}
|
{{ $item->is_active ? __('Aktiv') : __('Inaktiv') }}
|
||||||
</flux:badge>
|
</flux:badge>
|
||||||
<flux:badge color="amber" size="sm">
|
<span class="font-semibold text-sm truncate">{{ $c['title'] ?? '–' }}</span>
|
||||||
{{ match($item->content['type'] ?? '') {
|
|
||||||
'intro' => 'Intro',
|
|
||||||
'product-hero' => 'Produkt-Hero',
|
|
||||||
'product-details' => 'Produkt-Details',
|
|
||||||
'product-impulse' => 'Produkt-Impuls',
|
|
||||||
default => $item->content['type'] ?? '–',
|
|
||||||
} }}
|
|
||||||
</flux:badge>
|
|
||||||
<span class="font-semibold text-sm">{{ $item->content['title'] ?? '–' }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs text-zinc-600 dark:text-zinc-400 space-x-4">
|
<div class="flex flex-wrap items-center gap-2 text-xs text-zinc-600 dark:text-zinc-400">
|
||||||
@if(!empty($item->content['price']))
|
<span>{{ number_format(($c['duration'] ?? 8000) / 1000, 1) }}s</span>
|
||||||
<span class="font-medium">{{ $item->content['price'] }}</span>
|
@if(!empty($c['price']) && ($c['show_price'] ?? ! empty($c['price'])))
|
||||||
@endif
|
<span class="font-medium">{{ $c['price'] }}</span>
|
||||||
<span>{{ number_format(($item->content['duration'] ?? 8000) / 1000, 1) }}s</span>
|
|
||||||
@if(!empty($item->content['badge_text']))
|
|
||||||
<span>{{ $item->content['badge_text'] }}</span>
|
|
||||||
@endif
|
@endif
|
||||||
|
@foreach($enabledBlocks as $block)
|
||||||
|
<flux:badge color="zinc" size="sm">{{ $block }}</flux:badge>
|
||||||
|
@endforeach
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,74 @@
|
||||||
@if($version->type->value === 'b2in')
|
@if($version->type->value === 'b2in')
|
||||||
|
@php
|
||||||
|
$footerShown = ($settings['show_footer'] ?? true) !== false;
|
||||||
|
$showLogo = ($settings['show_logo'] ?? true) !== false;
|
||||||
|
$showClaim = ($settings['show_claim'] ?? true) !== false;
|
||||||
|
$logoPos = $settings['logo_position'] ?? 'top-left';
|
||||||
|
$claimPos = $settings['claim_position'] ?? 'top-right';
|
||||||
|
$brandPositions = [
|
||||||
|
'top-left' => __('Oben links'),
|
||||||
|
'top-right' => __('Oben rechts'),
|
||||||
|
'bottom-left' => __('Unten links'),
|
||||||
|
'bottom-right' => __('Unten rechts'),
|
||||||
|
];
|
||||||
|
@endphp
|
||||||
<flux:select wire:model="settings.theme" label="Theme">
|
<flux:select wire:model="settings.theme" label="Theme">
|
||||||
<option value="dark">Dark</option>
|
<option value="dark">Dark</option>
|
||||||
<option value="light">Light</option>
|
<option value="light">Light</option>
|
||||||
</flux:select>
|
</flux:select>
|
||||||
<div class="space-y-4 rounded-xl border border-zinc-200 p-4 dark:border-zinc-700">
|
<div class="space-y-4 rounded-xl border border-zinc-200 p-4 dark:border-zinc-700">
|
||||||
<flux:heading size="sm">{{ __('Header') }}</flux:heading>
|
<flux:heading size="sm">{{ __('Marke') }}</flux:heading>
|
||||||
|
<flux:subheading>{{ __('Logo und Claim. Standardmäßig oben im Header. Die Ecken lassen sich frei wählen.') }}</flux:subheading>
|
||||||
<livewire:admin.cms.display-media-picker
|
<livewire:admin.cms.display-media-picker
|
||||||
:value="null"
|
:value="null"
|
||||||
field="settings.header_logo_url"
|
field="settings.header_logo_url"
|
||||||
type="image"
|
type="image"
|
||||||
label="Header-Logo aus Mediathek"
|
label="Logo aus Mediathek"
|
||||||
:key="'picker-b2in-header-logo-' . $context . '-' . $version->id" />
|
:key="'picker-b2in-header-logo-' . $context . '-' . $version->id" />
|
||||||
<flux:input wire:model="settings.header_logo_url" label="Header-Logo URL" placeholder="../assets/b2in-logo-positive.svg" />
|
<div class="flex items-end gap-3">
|
||||||
|
<x-media-thumb :url="$settings['header_logo_url'] ?? ''" />
|
||||||
|
<flux:input wire:model.live.debounce.500ms="settings.header_logo_url" label="Logo URL" placeholder="../assets/b2in-logo-positive.svg" class="flex-1" />
|
||||||
|
</div>
|
||||||
<flux:input wire:model="settings.header_claim" label="Claim" placeholder="Connecting Design & Property" />
|
<flux:input wire:model="settings.header_claim" label="Claim" placeholder="Connecting Design & Property" />
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-6">
|
||||||
|
<flux:switch wire:model.live="settings.show_logo" label="Logo anzeigen" />
|
||||||
|
<flux:switch wire:model.live="settings.show_claim" label="Claim anzeigen" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if($showLogo)
|
||||||
|
<flux:select wire:model.live="settings.logo_position" label="Logo-Position">
|
||||||
|
@foreach($brandPositions as $value => $label)
|
||||||
|
<option value="{{ $value }}" @disabled($footerShown && str_starts_with($value, 'bottom'))>{{ $label }}</option>
|
||||||
|
@endforeach
|
||||||
|
</flux:select>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if($showClaim)
|
||||||
|
<flux:select wire:model.live="settings.claim_position" label="Claim-Position">
|
||||||
|
@foreach($brandPositions as $value => $label)
|
||||||
|
<option value="{{ $value }}"
|
||||||
|
@disabled(($showLogo && $value === $logoPos) || ($footerShown && str_starts_with($value, 'bottom')))>
|
||||||
|
{{ $label }}{{ ($showLogo && $value === $logoPos) ? ' '.__('(Logo)') : '' }}
|
||||||
|
</option>
|
||||||
|
@endforeach
|
||||||
|
</flux:select>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if($footerShown)
|
||||||
|
<flux:callout variant="secondary" icon="information-circle">
|
||||||
|
<flux:callout.text>{{ __('Untere Ecken sind nur verfügbar, wenn der Footer ausgeblendet ist.') }}</flux:callout.text>
|
||||||
|
</flux:callout>
|
||||||
|
@endif
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-4 rounded-xl border border-zinc-200 p-4 dark:border-zinc-700">
|
<div class="space-y-4 rounded-xl border border-zinc-200 p-4 dark:border-zinc-700">
|
||||||
<flux:heading size="sm">{{ __('Footer & QR') }}</flux:heading>
|
<flux:heading size="sm">{{ __('Footer & QR') }}</flux:heading>
|
||||||
|
<flux:switch wire:model.live="settings.show_footer" label="Footer anzeigen"
|
||||||
|
description="Blendet die Fußzeile mit Domain, Name und QR-Code ein oder aus." />
|
||||||
<flux:input wire:model="settings.footer_prefix" label="Footer-Präfix" placeholder="by" />
|
<flux:input wire:model="settings.footer_prefix" label="Footer-Präfix" placeholder="by" />
|
||||||
<flux:input wire:model="settings.footer_name" label="Footer Name" placeholder="z.B. Marcel Scheibe" />
|
<flux:input wire:model="settings.footer_name" label="Footer Name" placeholder="z.B. Marcel Scheibe" />
|
||||||
<flux:input wire:model="settings.footer_url" label="Footer Domain" placeholder="z.B. b2in.de" />
|
<flux:input wire:model="settings.footer_url" label="Footer Domain" placeholder="z.B. b2in.eu" />
|
||||||
<flux:input wire:model="settings.qr_url" label="QR-URL (optional)" placeholder="https://b2in.de"
|
<flux:input wire:model="settings.qr_url" label="QR-URL (optional)" placeholder="https://b2in.e"
|
||||||
description="Leer = QR-Code nutzt die Footer-Domain." />
|
description="Leer = QR-Code nutzt die Footer-Domain." />
|
||||||
</div>
|
</div>
|
||||||
<flux:select wire:model="settings.transition.type" label="Transition">
|
<flux:select wire:model="settings.transition.type" label="Transition">
|
||||||
|
|
@ -32,30 +81,14 @@
|
||||||
<flux:checkbox wire:model="settings.display_active" label="Display aktiv" />
|
<flux:checkbox wire:model="settings.display_active" label="Display aktiv" />
|
||||||
@elseif($version->type->value === 'offers')
|
@elseif($version->type->value === 'offers')
|
||||||
<flux:checkbox wire:model="settings.loop" label="Endlosschleife" />
|
<flux:checkbox wire:model="settings.loop" label="Endlosschleife" />
|
||||||
<div class="space-y-4 rounded-xl border border-zinc-200 p-4 dark:border-zinc-700">
|
|
||||||
<flux:heading size="sm">{{ __('Branding') }}</flux:heading>
|
|
||||||
<livewire:admin.cms.display-media-picker
|
|
||||||
:value="null"
|
|
||||||
field="settings.logo_url"
|
|
||||||
type="image"
|
|
||||||
label="Logo aus Mediathek"
|
|
||||||
:key="'picker-offers-logo-' . $context . '-' . $version->id" />
|
|
||||||
<flux:input wire:model="settings.logo_url" label="Logo URL" placeholder="../logo-cabinet-300.png" />
|
|
||||||
<flux:input wire:model="settings.brand_text" label="Brand-Text" placeholder="Bielefeld" />
|
|
||||||
</div>
|
|
||||||
<div class="space-y-4 rounded-xl border border-zinc-200 p-4 dark:border-zinc-700">
|
|
||||||
<flux:heading size="sm">{{ __('Footer & QR für alle Slides') }}</flux:heading>
|
|
||||||
<flux:input wire:model="settings.footer_claim" label="Footer-Claim" placeholder="z.B. Planung • Beratung • Lieferung & Montage" />
|
|
||||||
<flux:input wire:model="settings.footer_url" label="Web/QR-URL" placeholder="https://cabinet-bielefeld.de"
|
|
||||||
description="Wird als QR-Ziel genutzt, wenn der einzelne Slide keine eigene QR-URL hat." />
|
|
||||||
<flux:input wire:model="settings.qr_default_title" label="Standard QR-Titel" placeholder="Kontakt" />
|
|
||||||
<flux:input wire:model="settings.qr_subtitle" label="QR-Unterzeile" placeholder="QR scannen" />
|
|
||||||
</div>
|
|
||||||
<flux:select wire:model="settings.transition.type" label="Transition">
|
<flux:select wire:model="settings.transition.type" label="Transition">
|
||||||
<option value="fade">Fade</option>
|
<option value="fade">Fade</option>
|
||||||
<option value="slide">Slide</option>
|
<option value="slide">Slide</option>
|
||||||
</flux:select>
|
</flux:select>
|
||||||
<flux:input wire:model="settings.transition.duration" type="number" label="Transition-Dauer (ms)" />
|
<flux:input wire:model="settings.transition.duration" type="number" label="Transition-Dauer (ms)" />
|
||||||
|
<flux:callout variant="secondary" icon="information-circle">
|
||||||
|
<flux:callout.text>{{ __('Logo, Marken-Text, QR-Code und Kontakt werden je Angebot direkt am Element gepflegt.') }}</flux:callout.text>
|
||||||
|
</flux:callout>
|
||||||
@elseif($version->type->value === 'video-display')
|
@elseif($version->type->value === 'video-display')
|
||||||
<flux:input wire:model="settings.qr_label" label="QR-Label im Footer" placeholder="Website" />
|
<flux:input wire:model="settings.qr_label" label="QR-Label im Footer" placeholder="Website" />
|
||||||
@endif
|
@endif
|
||||||
|
|
|
||||||
|
|
@ -16,47 +16,61 @@
|
||||||
</flux:button>
|
</flux:button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if($videos->isEmpty())
|
@if ($videos->isEmpty())
|
||||||
<div class="text-center py-12 text-zinc-500 dark:text-zinc-400">
|
<div class="text-center py-12 text-zinc-500 dark:text-zinc-400">
|
||||||
<flux:icon.film class="w-16 h-16 mx-auto mb-4 opacity-50" />
|
<flux:icon.film class="w-16 h-16 mx-auto mb-4 opacity-50" />
|
||||||
<p>{{ __('Noch keine Videos vorhanden.') }}</p>
|
<p>{{ __('Noch keine Videos vorhanden.') }}</p>
|
||||||
</div>
|
</div>
|
||||||
@else
|
@else
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
@foreach($videos as $index => $item)
|
@foreach ($videos as $index => $item)
|
||||||
<div wire:key="item-{{ $item->id }}"
|
<div wire:key="item-{{ $item->id }}"
|
||||||
class="flex items-center gap-4 p-4 bg-zinc-50 dark:bg-zinc-800 rounded-lg border border-zinc-200 dark:border-zinc-700 transition">
|
class="flex items-center gap-4 p-4 bg-zinc-50 dark:bg-zinc-800 rounded-lg border border-zinc-200 dark:border-zinc-700 transition">
|
||||||
|
|
||||||
<div class="flex flex-col gap-1">
|
<div class="flex flex-col gap-1">
|
||||||
@if($index > 0)
|
@if ($index > 0)
|
||||||
<flux:button wire:click="moveItem({{ $item->id }}, 'up')" size="xs" variant="ghost" icon="chevron-up" class="text-zinc-400 hover:text-zinc-600"></flux:button>
|
<flux:button wire:click="moveItem({{ $item->id }}, 'up')" size="xs" variant="ghost"
|
||||||
|
icon="chevron-up" class="text-zinc-400 hover:text-zinc-600"></flux:button>
|
||||||
@endif
|
@endif
|
||||||
@if($index < count($videos) - 1)
|
@if ($index < count($videos) - 1)
|
||||||
<flux:button wire:click="moveItem({{ $item->id }}, 'down')" size="xs" variant="ghost" icon="chevron-down" class="text-zinc-400 hover:text-zinc-600"></flux:button>
|
<flux:button wire:click="moveItem({{ $item->id }}, 'down')" size="xs"
|
||||||
|
variant="ghost" icon="chevron-down" class="text-zinc-400 hover:text-zinc-600">
|
||||||
|
</flux:button>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex h-16 w-12 shrink-0 items-center justify-center rounded-lg bg-black text-[10px] font-semibold uppercase text-white">
|
<x-media-thumb :url="$item->content['filename'] ?? ''" size="h-16 w-12" />
|
||||||
Video
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<div class="flex items-center gap-3 mb-1">
|
<div class="flex items-center gap-3 mb-1">
|
||||||
<flux:badge :color="$item->is_active ? 'green' : 'zinc'" size="sm">
|
<flux:badge :color="$item->is_active ? 'green' : 'zinc'" size="sm">
|
||||||
{{ $item->is_active ? __('Aktiv') : __('Inaktiv') }}
|
{{ $item->is_active ? __('Aktiv') : __('Inaktiv') }}
|
||||||
</flux:badge>
|
</flux:badge>
|
||||||
<span class="font-semibold text-sm">{{ $item->content['title'] ?? $item->content['filename'] ?? '–' }}</span>
|
<span
|
||||||
|
class="font-semibold text-sm">{{ $item->content['title'] ?? ($item->content['filename'] ?? '–') }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs text-zinc-600 dark:text-zinc-400 space-x-4">
|
@php
|
||||||
<span>{{ $item->content['filename'] ?? '–' }}</span>
|
$videoSource = $item->content['filename'] ?? '';
|
||||||
|
$isMediaLibrarySource =
|
||||||
|
str_starts_with($videoSource, '/storage/') || str_starts_with($videoSource, 'http');
|
||||||
|
@endphp
|
||||||
|
<div class="flex flex-wrap items-center gap-2 text-xs text-zinc-600 dark:text-zinc-400">
|
||||||
|
<flux:badge size="sm" :color="$isMediaLibrarySource ? 'sky' : 'zinc'">
|
||||||
|
{{ $isMediaLibrarySource ? __('Mediathek') : __('Legacy-Datei') }}
|
||||||
|
</flux:badge>
|
||||||
|
<span class="truncate">{{ $videoSource ?: '–' }}</span>
|
||||||
<span>Position: {{ $item->content['position'] ?? 25 }}%</span>
|
<span>Position: {{ $item->content['position'] ?? 25 }}%</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<flux:button wire:click="toggleItemStatus({{ $item->id }})" size="sm" variant="ghost" :icon="$item->is_active ? 'eye-slash' : 'eye'"></flux:button>
|
<flux:button wire:click="toggleItemStatus({{ $item->id }})" size="sm" variant="ghost"
|
||||||
<flux:button wire:click="openItemModal({{ $item->id }})" size="sm" variant="ghost" icon="pencil"></flux:button>
|
:icon="$item->is_active ? 'eye-slash' : 'eye'"></flux:button>
|
||||||
<flux:button wire:click="deleteItem({{ $item->id }})" wire:confirm="Möchten Sie diesen Eintrag wirklich löschen?" size="sm" variant="ghost" icon="trash" class="text-red-600 hover:text-red-700"></flux:button>
|
<flux:button wire:click="openItemModal({{ $item->id }})" size="sm" variant="ghost"
|
||||||
|
icon="pencil"></flux:button>
|
||||||
|
<flux:button wire:click="deleteItem({{ $item->id }})"
|
||||||
|
wire:confirm="Möchten Sie diesen Eintrag wirklich löschen?" size="sm" variant="ghost"
|
||||||
|
icon="trash" class="text-red-600 hover:text-red-700"></flux:button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@endforeach
|
@endforeach
|
||||||
|
|
@ -69,34 +83,40 @@
|
||||||
<div class="flex items-center justify-between mb-6">
|
<div class="flex items-center justify-between mb-6">
|
||||||
<div>
|
<div>
|
||||||
<flux:heading size="lg">{{ __('Footer-Inhalte') }}</flux:heading>
|
<flux:heading size="lg">{{ __('Footer-Inhalte') }}</flux:heading>
|
||||||
<flux:subheading>{{ __('Inhalte werden im Footer rotiert') }}</flux:subheading>
|
<flux:subheading>{{ __('Inhalte werden im Footer rotiert / ohne Inhalte bleibt der untere Teil frei.') }}
|
||||||
|
</flux:subheading>
|
||||||
</div>
|
</div>
|
||||||
<flux:button wire:click="openItemModal(null, 'footer')" icon="plus">
|
<flux:button wire:click="openItemModal(null, 'footer')" icon="plus">
|
||||||
{{ __('Inhalt hinzufügen') }}
|
{{ __('Inhalt hinzufügen') }}
|
||||||
</flux:button>
|
</flux:button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if($footers->isEmpty())
|
@if ($footers->isEmpty())
|
||||||
<div class="text-center py-12 text-zinc-500 dark:text-zinc-400">
|
<div class="text-center py-12 text-zinc-500 dark:text-zinc-400">
|
||||||
<flux:icon.document-text class="w-16 h-16 mx-auto mb-4 opacity-50" />
|
<flux:icon.document-text class="w-16 h-16 mx-auto mb-4 opacity-50" />
|
||||||
<p>{{ __('Noch keine Footer-Inhalte vorhanden.') }}</p>
|
<p>{{ __('Noch keine Footer-Inhalte vorhanden.') }}</p>
|
||||||
</div>
|
</div>
|
||||||
@else
|
@else
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
@foreach($footers as $index => $item)
|
@foreach ($footers as $index => $item)
|
||||||
<div wire:key="item-{{ $item->id }}"
|
<div wire:key="item-{{ $item->id }}"
|
||||||
class="flex items-center gap-4 p-4 bg-zinc-50 dark:bg-zinc-800 rounded-lg border border-zinc-200 dark:border-zinc-700 transition">
|
class="flex items-center gap-4 p-4 bg-zinc-50 dark:bg-zinc-800 rounded-lg border border-zinc-200 dark:border-zinc-700 transition">
|
||||||
|
|
||||||
<div class="flex flex-col gap-1">
|
<div class="flex flex-col gap-1">
|
||||||
@if($index > 0)
|
@if ($index > 0)
|
||||||
<flux:button wire:click="moveItem({{ $item->id }}, 'up')" size="xs" variant="ghost" icon="chevron-up" class="text-zinc-400 hover:text-zinc-600"></flux:button>
|
<flux:button wire:click="moveItem({{ $item->id }}, 'up')" size="xs"
|
||||||
|
variant="ghost" icon="chevron-up" class="text-zinc-400 hover:text-zinc-600">
|
||||||
|
</flux:button>
|
||||||
@endif
|
@endif
|
||||||
@if($index < count($footers) - 1)
|
@if ($index < count($footers) - 1)
|
||||||
<flux:button wire:click="moveItem({{ $item->id }}, 'down')" size="xs" variant="ghost" icon="chevron-down" class="text-zinc-400 hover:text-zinc-600"></flux:button>
|
<flux:button wire:click="moveItem({{ $item->id }}, 'down')" size="xs"
|
||||||
|
variant="ghost" icon="chevron-down" class="text-zinc-400 hover:text-zinc-600">
|
||||||
|
</flux:button>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex h-16 w-12 shrink-0 flex-col justify-end rounded-lg bg-zinc-900 p-1 text-[8px] text-white">
|
<div
|
||||||
|
class="flex h-16 w-12 shrink-0 flex-col justify-end rounded-lg bg-zinc-900 p-1 text-[8px] text-white">
|
||||||
<div class="truncate text-zinc-400">{{ $item->content['headline'] ?? 'Footer' }}</div>
|
<div class="truncate text-zinc-400">{{ $item->content['headline'] ?? 'Footer' }}</div>
|
||||||
<div class="truncate font-semibold">{{ $item->content['subline'] ?? '' }}</div>
|
<div class="truncate font-semibold">{{ $item->content['subline'] ?? '' }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -110,16 +130,20 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs text-zinc-600 dark:text-zinc-400">
|
<div class="text-xs text-zinc-600 dark:text-zinc-400">
|
||||||
{{ $item->content['subline'] ?? '' }}
|
{{ $item->content['subline'] ?? '' }}
|
||||||
@if(!empty($item->content['url']))
|
@if (!empty($item->content['url']))
|
||||||
<span class="ml-2">{{ Str::limit($item->content['url'], 40) }}</span>
|
<span class="ml-2">{{ Str::limit($item->content['url'], 40) }}</span>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<flux:button wire:click="toggleItemStatus({{ $item->id }})" size="sm" variant="ghost" :icon="$item->is_active ? 'eye-slash' : 'eye'"></flux:button>
|
<flux:button wire:click="toggleItemStatus({{ $item->id }})" size="sm" variant="ghost"
|
||||||
<flux:button wire:click="openItemModal({{ $item->id }})" size="sm" variant="ghost" icon="pencil"></flux:button>
|
:icon="$item->is_active ? 'eye-slash' : 'eye'"></flux:button>
|
||||||
<flux:button wire:click="deleteItem({{ $item->id }})" wire:confirm="Möchten Sie diesen Eintrag wirklich löschen?" size="sm" variant="ghost" icon="trash" class="text-red-600 hover:text-red-700"></flux:button>
|
<flux:button wire:click="openItemModal({{ $item->id }})" size="sm" variant="ghost"
|
||||||
|
icon="pencil"></flux:button>
|
||||||
|
<flux:button wire:click="deleteItem({{ $item->id }})"
|
||||||
|
wire:confirm="Möchten Sie diesen Eintrag wirklich löschen?" size="sm" variant="ghost"
|
||||||
|
icon="trash" class="text-red-600 hover:text-red-700"></flux:button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@endforeach
|
@endforeach
|
||||||
|
|
|
||||||
|
|
@ -35,26 +35,26 @@ mount(function ($hubId = null) {
|
||||||
|
|
||||||
// Auto-generate slug from name
|
// Auto-generate slug from name
|
||||||
$updatedName = function ($value) {
|
$updatedName = function ($value) {
|
||||||
if (!$this->hubId) { // Only auto-generate for new hubs
|
if (!$this->hubId) {
|
||||||
|
// Only auto-generate for new hubs
|
||||||
$this->slug = \Illuminate\Support\Str::slug($value);
|
$this->slug = \Illuminate\Support\Str::slug($value);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
$locations = computed(function () {
|
$locations = computed(function () {
|
||||||
if (!$this->hubId) return collect();
|
if (!$this->hubId) {
|
||||||
|
return collect();
|
||||||
|
}
|
||||||
|
|
||||||
return \App\Models\HubLocation::where('hub_id', $this->hubId)
|
return \App\Models\HubLocation::where('hub_id', $this->hubId)->when($this->zipSearch, fn($q) => $q->where('zip_code', 'like', "%{$this->zipSearch}%")->orWhere('city_name', 'like', "%{$this->zipSearch}%"))->orderBy('zip_code')->paginate(50);
|
||||||
->when($this->zipSearch, fn($q) => $q->where('zip_code', 'like', "%{$this->zipSearch}%")
|
|
||||||
->orWhere('city_name', 'like', "%{$this->zipSearch}%"))
|
|
||||||
->orderBy('zip_code')
|
|
||||||
->paginate(50);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
$partners = computed(function () {
|
$partners = computed(function () {
|
||||||
if (!$this->hubId) return collect();
|
if (!$this->hubId) {
|
||||||
|
return collect();
|
||||||
|
}
|
||||||
|
|
||||||
return \App\Models\Partner::where('hub_id', $this->hubId)
|
return \App\Models\Partner::where('hub_id', $this->hubId)->get();
|
||||||
->get();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Dummy save function
|
// Dummy save function
|
||||||
|
|
@ -103,12 +103,12 @@ $deleteLocation = function ($id) {
|
||||||
|
|
||||||
{{-- Flash Message --}}
|
{{-- Flash Message --}}
|
||||||
@if (session()->has('message'))
|
@if (session()->has('message'))
|
||||||
<flux:card class="p-4 bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800">
|
<flux:card class="p-4 bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<flux:icon.check-circle class="h-5 w-5 text-green-600 dark:text-green-400" />
|
<flux:icon.check-circle class="h-5 w-5 text-green-600 dark:text-green-400" />
|
||||||
<span class="text-sm text-green-900 dark:text-green-100">{{ session('message') }}</span>
|
<span class="text-sm text-green-900 dark:text-green-100">{{ session('message') }}</span>
|
||||||
</div>
|
</div>
|
||||||
</flux:card>
|
</flux:card>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
{{-- Tabs --}}
|
{{-- Tabs --}}
|
||||||
|
|
@ -119,364 +119,360 @@ $deleteLocation = function ($id) {
|
||||||
</flux:tabs>
|
</flux:tabs>
|
||||||
|
|
||||||
{{-- TAB 1: Identität & Design --}}
|
{{-- TAB 1: Identität & Design --}}
|
||||||
@if($activeTab === 'identity')
|
@if ($activeTab === 'identity')
|
||||||
<flux:card class="p-6 space-y-6">
|
<flux:card class="p-6 space-y-6">
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
{{-- Basis-Informationen --}}
|
{{-- Basis-Informationen --}}
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<flux:field>
|
<flux:field>
|
||||||
<flux:label>{{ __('Hub-Name') }} <span class="text-red-500">*</span></flux:label>
|
<flux:label>{{ __('Hub-Name') }} <span class="text-red-500">*</span></flux:label>
|
||||||
<flux:input wire:model.live="name" placeholder="Ostwestfalen-Lippe" />
|
<flux:input wire:model.live="name" placeholder="Ostwestfalen-Lippe" />
|
||||||
<flux:description>{{ __('Wird dem Kunden angezeigt, z.B. "Region Ostwestfalen-Lippe"') }}</flux:description>
|
<flux:description>{{ __('Wird dem Kunden angezeigt, z.B. "Region Ostwestfalen-Lippe"') }}
|
||||||
</flux:field>
|
</flux:description>
|
||||||
|
</flux:field>
|
||||||
|
|
||||||
<flux:field>
|
<flux:field>
|
||||||
<flux:label>{{ __('URL-Slug') }} <span class="text-red-500">*</span></flux:label>
|
<flux:label>{{ __('URL-Slug') }} <span class="text-red-500">*</span></flux:label>
|
||||||
<flux:input wire:model="slug" placeholder="owl" />
|
<flux:input wire:model="slug" placeholder="owl" />
|
||||||
<flux:description>{{ __('Für saubere URLs, z.B. b2in.de/region/owl') }}</flux:description>
|
<flux:description>{{ __('Für saubere URLs, z.B. b2in.eu/region/owl') }}</flux:description>
|
||||||
</flux:field>
|
</flux:field>
|
||||||
|
|
||||||
<flux:field>
|
<flux:field>
|
||||||
<flux:label>{{ __('Status') }}</flux:label>
|
<flux:label>{{ __('Status') }}</flux:label>
|
||||||
<flux:checkbox wire:model="is_active">
|
<flux:checkbox wire:model="is_active">
|
||||||
{{ __('Hub ist aktiv und für Kunden sichtbar') }}
|
{{ __('Hub ist aktiv und für Kunden sichtbar') }}
|
||||||
</flux:checkbox>
|
</flux:checkbox>
|
||||||
<flux:description>
|
<flux:description>
|
||||||
{{ __('Inaktive Hubs sind im "Coming Soon"-Modus') }}
|
{{ __('Inaktive Hubs sind im "Coming Soon"-Modus') }}
|
||||||
</flux:description>
|
</flux:description>
|
||||||
</flux:field>
|
</flux:field>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{-- Vorschau --}}
|
{{-- Vorschau --}}
|
||||||
<div class="p-4 bg-zinc-50 dark:bg-zinc-800 rounded-lg">
|
<div class="p-4 bg-zinc-50 dark:bg-zinc-800 rounded-lg">
|
||||||
<h4 class="font-semibold mb-3 text-zinc-900 dark:text-zinc-100">{{ __('Vorschau: Kunden-Landingpage') }}</h4>
|
<h4 class="font-semibold mb-3 text-zinc-900 dark:text-zinc-100">
|
||||||
<div class="relative h-48 rounded-lg overflow-hidden bg-zinc-200 dark:bg-zinc-700 shadow-lg">
|
{{ __('Vorschau: Kunden-Landingpage') }}</h4>
|
||||||
@if($keyvisual)
|
<div class="relative h-48 rounded-lg overflow-hidden bg-zinc-200 dark:bg-zinc-700 shadow-lg">
|
||||||
<img src="{{ $keyvisual }}" class="w-full h-full object-cover" alt="Keyvisual" />
|
@if ($keyvisual)
|
||||||
@else
|
<img src="{{ $keyvisual }}" class="w-full h-full object-cover" alt="Keyvisual" />
|
||||||
<div class="flex items-center justify-center h-full">
|
@else
|
||||||
<div class="text-center">
|
<div class="flex items-center justify-center h-full">
|
||||||
<flux:icon.photo class="w-12 h-12 text-zinc-400 mx-auto mb-2" />
|
<div class="text-center">
|
||||||
<p class="text-sm text-zinc-500">{{ __('Keyvisual hochladen') }}</p>
|
<flux:icon.photo class="w-12 h-12 text-zinc-400 mx-auto mb-2" />
|
||||||
|
<p class="text-sm text-zinc-500">{{ __('Keyvisual hochladen') }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
<div class="absolute bottom-0 left-0 right-0 p-4 bg-gradient-to-t from-black/80 to-transparent">
|
||||||
|
<h3 class="text-xl font-bold text-white">
|
||||||
|
{{ $name ?: __('Ihr Hub-Name') }}
|
||||||
|
</h3>
|
||||||
|
<p class="text-sm text-zinc-200">
|
||||||
|
{{ __('Die besten Marken Europas, von Händlern aus Ihrer Nachbarschaft') }}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
@if ($emblem)
|
||||||
|
<div class="absolute top-3 right-3 w-12 h-12 bg-white rounded-full p-2 shadow-lg">
|
||||||
|
<img src="{{ $emblem }}" alt="Emblem" class="w-full h-full object-contain" />
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
</div>
|
</div>
|
||||||
@endif
|
|
||||||
<div class="absolute bottom-0 left-0 right-0 p-4 bg-gradient-to-t from-black/80 to-transparent">
|
|
||||||
<h3 class="text-xl font-bold text-white">
|
|
||||||
{{ $name ?: __('Ihr Hub-Name') }}
|
|
||||||
</h3>
|
|
||||||
<p class="text-sm text-zinc-200">
|
|
||||||
{{ __('Die besten Marken Europas, von Händlern aus Ihrer Nachbarschaft') }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
@if($emblem)
|
|
||||||
<div class="absolute top-3 right-3 w-12 h-12 bg-white rounded-full p-2 shadow-lg">
|
|
||||||
<img src="{{ $emblem }}" alt="Emblem" class="w-full h-full object-contain" />
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<flux:separator />
|
<flux:separator />
|
||||||
|
|
||||||
{{-- Keyvisual Upload --}}
|
{{-- Keyvisual Upload --}}
|
||||||
<flux:field>
|
<flux:field>
|
||||||
<flux:label>{{ __('Keyvisual (Hintergrundbild)') }}</flux:label>
|
<flux:label>{{ __('Keyvisual (Hintergrundbild)') }}</flux:label>
|
||||||
{{-- <flux:input type="file" wire:model="keyvisual" accept="image/*" /> --}}
|
{{-- <flux:input type="file" wire:model="keyvisual" accept="image/*" /> --}}
|
||||||
<flux:description>
|
<flux:description>
|
||||||
{{ __('Atmosphärisches Regionalbild für emotionalen Einstieg') }}
|
{{ __('Atmosphärisches Regionalbild für emotionalen Einstieg') }}
|
||||||
<br>
|
<br>
|
||||||
<span class="text-xs">
|
<span class="text-xs">
|
||||||
{{ __('Beispiele: Hermannsdenkmal (OWL), Skyline (Frankfurt), Hafen (Hamburg)') }}
|
{{ __('Beispiele: Hermannsdenkmal (OWL), Skyline (Frankfurt), Hafen (Hamburg)') }}
|
||||||
• {{ __('Empfohlen: 1920x800px, max. 2MB') }}
|
• {{ __('Empfohlen: 1920x800px, max. 2MB') }}
|
||||||
</span>
|
</span>
|
||||||
</flux:description>
|
</flux:description>
|
||||||
</flux:field>
|
</flux:field>
|
||||||
|
|
||||||
{{-- Wappen Upload --}}
|
{{-- Wappen Upload --}}
|
||||||
<flux:field>
|
<flux:field>
|
||||||
<flux:label>{{ __('Wappen / Emblem') }}</flux:label>
|
<flux:label>{{ __('Wappen / Emblem') }}</flux:label>
|
||||||
{{-- <flux:input type="file" wire:model="emblem" accept="image/*" /> --}}
|
{{-- <flux:input type="file" wire:model="emblem" accept="image/*" /> --}}
|
||||||
<flux:description>
|
<flux:description>
|
||||||
{{ __('Offizielles Wappen oder Logo der Region für Vertrauen & Offizialität') }}
|
{{ __('Offizielles Wappen oder Logo der Region für Vertrauen & Offizialität') }}
|
||||||
<br>
|
<br>
|
||||||
<span class="text-xs">
|
<span class="text-xs">
|
||||||
{{ __('Beispiele: Sparrenburg-Logo, Landeswappen') }}
|
{{ __('Beispiele: Sparrenburg-Logo, Landeswappen') }}
|
||||||
• {{ __('Empfohlen: 256x256px, PNG mit transparentem Hintergrund') }}
|
• {{ __('Empfohlen: 256x256px, PNG mit transparentem Hintergrund') }}
|
||||||
</span>
|
</span>
|
||||||
</flux:description>
|
</flux:description>
|
||||||
</flux:field>
|
</flux:field>
|
||||||
</flux:card>
|
</flux:card>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
{{-- TAB 2: Geografie & PLZ --}}
|
{{-- TAB 2: Geografie & PLZ --}}
|
||||||
@if($activeTab === 'geography')
|
@if ($activeTab === 'geography')
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
|
|
||||||
{{-- Info-Box --}}
|
{{-- Info-Box --}}
|
||||||
<flux:card class="p-4 bg-yellow-50 dark:bg-yellow-900/20 border-yellow-200 dark:border-yellow-800">
|
<flux:card class="p-4 bg-yellow-50 dark:bg-yellow-900/20 border-yellow-200 dark:border-yellow-800">
|
||||||
<div class="flex items-start gap-3">
|
<div class="flex items-start gap-3">
|
||||||
<flux:icon.light-bulb class="h-5 w-5 text-yellow-600 dark:text-yellow-400 flex-shrink-0 mt-0.5" />
|
<flux:icon.light-bulb class="h-5 w-5 text-yellow-600 dark:text-yellow-400 flex-shrink-0 mt-0.5" />
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="text-sm font-semibold text-yellow-900 dark:text-yellow-100">
|
<div class="text-sm font-semibold text-yellow-900 dark:text-yellow-100">
|
||||||
{{ __('Die Mapping-Engine') }}
|
{{ __('Die Mapping-Engine') }}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs text-yellow-700 dark:text-yellow-300 mt-1">
|
<div class="text-xs text-yellow-700 dark:text-yellow-300 mt-1">
|
||||||
{{ __('Hier ordnen Sie Postleitzahlen diesem Hub zu. Gibt ein Kunde seine PLZ ein, wird er automatisch diesem regionalen Marktplatz zugewiesen und sieht lokale Händler.') }}
|
{{ __('Hier ordnen Sie Postleitzahlen diesem Hub zu. Gibt ein Kunde seine PLZ ein, wird er automatisch diesem regionalen Marktplatz zugewiesen und sieht lokale Händler.') }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</flux:card>
|
||||||
</flux:card>
|
|
||||||
|
|
||||||
{{-- PLZ-Import Tools --}}
|
{{-- PLZ-Import Tools --}}
|
||||||
<flux:card class="p-6">
|
<flux:card class="p-6">
|
||||||
<flux:heading size="lg" class="mb-4">{{ __('Postleitzahlen hinzufügen') }}</flux:heading>
|
<flux:heading size="lg" class="mb-4">{{ __('Postleitzahlen hinzufügen') }}</flux:heading>
|
||||||
|
|
||||||
<flux:tabs wire:model.live="importMethod" variant="segmented">
|
<flux:tabs wire:model.live="importMethod" variant="segmented">
|
||||||
<flux:tab name="single" icon="plus">{{ __('Einzeln') }}</flux:tab>
|
<flux:tab name="single" icon="plus">{{ __('Einzeln') }}</flux:tab>
|
||||||
<flux:tab name="range" icon="arrows-right-left">{{ __('Bereich') }}</flux:tab>
|
<flux:tab name="range" icon="arrows-right-left">{{ __('Bereich') }}</flux:tab>
|
||||||
<flux:tab name="csv" icon="document">{{ __('CSV-Import') }}</flux:tab>
|
<flux:tab name="csv" icon="document">{{ __('CSV-Import') }}</flux:tab>
|
||||||
</flux:tabs>
|
</flux:tabs>
|
||||||
|
|
||||||
<div class="mt-6">
|
<div class="mt-6">
|
||||||
{{-- Einzelne PLZ --}}
|
{{-- Einzelne PLZ --}}
|
||||||
@if($importMethod === 'single')
|
@if ($importMethod === 'single')
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div class="grid grid-cols-2 gap-4">
|
<div class="grid grid-cols-2 gap-4">
|
||||||
<flux:field>
|
<flux:field>
|
||||||
<flux:label>{{ __('Postleitzahl') }} <span class="text-red-500">*</span></flux:label>
|
<flux:label>{{ __('Postleitzahl') }} <span class="text-red-500">*</span>
|
||||||
<flux:input wire:model="newZipCode" placeholder="33602" />
|
</flux:label>
|
||||||
</flux:field>
|
<flux:input wire:model="newZipCode" placeholder="33602" />
|
||||||
<flux:field>
|
</flux:field>
|
||||||
<flux:label>{{ __('Stadt') }} <span class="text-red-500">*</span></flux:label>
|
<flux:field>
|
||||||
<flux:input wire:model="newCityName" placeholder="Bielefeld" />
|
<flux:label>{{ __('Stadt') }} <span class="text-red-500">*</span></flux:label>
|
||||||
</flux:field>
|
<flux:input wire:model="newCityName" placeholder="Bielefeld" />
|
||||||
|
</flux:field>
|
||||||
|
</div>
|
||||||
|
<flux:button wire:click="addSingleZip" icon="plus">
|
||||||
|
{{ __('PLZ hinzufügen') }}
|
||||||
|
</flux:button>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
{{-- PLZ-Bereich --}}
|
||||||
|
@if ($importMethod === 'range')
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<flux:field>
|
||||||
|
<flux:label>{{ __('Von PLZ') }} <span class="text-red-500">*</span></flux:label>
|
||||||
|
<flux:input wire:model="rangeStart" placeholder="33000" />
|
||||||
|
</flux:field>
|
||||||
|
<flux:field>
|
||||||
|
<flux:label>{{ __('Bis PLZ') }} <span class="text-red-500">*</span></flux:label>
|
||||||
|
<flux:input wire:model="rangeEnd" placeholder="33999" />
|
||||||
|
</flux:field>
|
||||||
|
</div>
|
||||||
|
<flux:button wire:click="addZipRange" icon="arrows-right-left">
|
||||||
|
{{ __('Bereich importieren') }}
|
||||||
|
</flux:button>
|
||||||
|
<flux:description>
|
||||||
|
⚠️
|
||||||
|
{{ __('Generiert automatisch alle PLZs im Bereich. Kann bei großen Bereichen mehrere Minuten dauern!') }}
|
||||||
|
</flux:description>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
{{-- CSV-Import --}}
|
||||||
|
@if ($importMethod === 'csv')
|
||||||
|
<div class="space-y-4">
|
||||||
|
<flux:field>
|
||||||
|
<flux:label>{{ __('CSV-Datei') }} <span class="text-red-500">*</span></flux:label>
|
||||||
|
<flux:input type="file" wire:model="csvFile" accept=".csv" />
|
||||||
|
<flux:description>
|
||||||
|
{{ __('Format: PLZ,Stadt (eine Zeile pro Eintrag)') }}
|
||||||
|
<br>
|
||||||
|
<span class="text-xs">{{ __('Beispiel:') }}</span>
|
||||||
|
<code class="text-xs bg-zinc-100 dark:bg-zinc-800 px-2 py-1 rounded">
|
||||||
|
33602,Bielefeld<br>33603,Bielefeld<br>33604,Bielefeld
|
||||||
|
</code>
|
||||||
|
</flux:description>
|
||||||
|
</flux:field>
|
||||||
|
<flux:button wire:click="importCsv" icon="arrow-up-tray">
|
||||||
|
{{ __('CSV importieren') }}
|
||||||
|
</flux:button>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</flux:card>
|
||||||
|
|
||||||
|
{{-- PLZ-Liste --}}
|
||||||
|
<flux:card>
|
||||||
|
<div class="p-6">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<flux:heading size="lg">
|
||||||
|
{{ __('Zugeordnete Postleitzahlen') }}
|
||||||
|
@if ($hubId)
|
||||||
|
<span class="text-sm font-normal text-zinc-600 dark:text-zinc-400">
|
||||||
|
({{ $this->locations->total() }} {{ __('gesamt') }})
|
||||||
|
</span>
|
||||||
|
@endif
|
||||||
|
</flux:heading>
|
||||||
|
<flux:input wire:model.live.debounce="zipSearch"
|
||||||
|
placeholder="{{ __('PLZ oder Stadt suchen...') }}" icon="magnifying-glass"
|
||||||
|
class="w-64" />
|
||||||
</div>
|
</div>
|
||||||
<flux:button wire:click="addSingleZip" icon="plus">
|
|
||||||
{{ __('PLZ hinzufügen') }}
|
|
||||||
</flux:button>
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
{{-- PLZ-Bereich --}}
|
@if ($hubId)
|
||||||
@if($importMethod === 'range')
|
<flux:table>
|
||||||
<div class="space-y-4">
|
<flux:table.columns>
|
||||||
<div class="grid grid-cols-2 gap-4">
|
<flux:table.column>{{ __('PLZ') }}</flux:table.column>
|
||||||
<flux:field>
|
<flux:table.column>{{ __('Stadt') }}</flux:table.column>
|
||||||
<flux:label>{{ __('Von PLZ') }} <span class="text-red-500">*</span></flux:label>
|
<flux:table.column class="text-right w-32">{{ __('Aktion') }}</flux:table.column>
|
||||||
<flux:input wire:model="rangeStart" placeholder="33000" />
|
</flux:table.columns>
|
||||||
</flux:field>
|
|
||||||
<flux:field>
|
|
||||||
<flux:label>{{ __('Bis PLZ') }} <span class="text-red-500">*</span></flux:label>
|
|
||||||
<flux:input wire:model="rangeEnd" placeholder="33999" />
|
|
||||||
</flux:field>
|
|
||||||
</div>
|
|
||||||
<flux:button wire:click="addZipRange" icon="arrows-right-left">
|
|
||||||
{{ __('Bereich importieren') }}
|
|
||||||
</flux:button>
|
|
||||||
<flux:description>
|
|
||||||
⚠️ {{ __('Generiert automatisch alle PLZs im Bereich. Kann bei großen Bereichen mehrere Minuten dauern!') }}
|
|
||||||
</flux:description>
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
{{-- CSV-Import --}}
|
<flux:table.rows>
|
||||||
@if($importMethod === 'csv')
|
@forelse($this->locations as $location)
|
||||||
<div class="space-y-4">
|
<flux:table.row :key="$location->id">
|
||||||
<flux:field>
|
<flux:table.cell>
|
||||||
<flux:label>{{ __('CSV-Datei') }} <span class="text-red-500">*</span></flux:label>
|
<span class="font-mono">{{ $location->zip_code }}</span>
|
||||||
<flux:input type="file" wire:model="csvFile" accept=".csv" />
|
</flux:table.cell>
|
||||||
<flux:description>
|
<flux:table.cell>{{ $location->city_name }}</flux:table.cell>
|
||||||
{{ __('Format: PLZ,Stadt (eine Zeile pro Eintrag)') }}
|
<flux:table.cell class="text-right">
|
||||||
<br>
|
<flux:button variant="ghost" size="sm" icon="trash"
|
||||||
<span class="text-xs">{{ __('Beispiel:') }}</span>
|
wire:click="deleteLocation({{ $location->id }})" />
|
||||||
<code class="text-xs bg-zinc-100 dark:bg-zinc-800 px-2 py-1 rounded">
|
</flux:table.cell>
|
||||||
33602,Bielefeld<br>33603,Bielefeld<br>33604,Bielefeld
|
</flux:table.row>
|
||||||
</code>
|
@empty
|
||||||
</flux:description>
|
<flux:table.row>
|
||||||
</flux:field>
|
<flux:table.cell colspan="3" class="text-center py-8">
|
||||||
<flux:button wire:click="importCsv" icon="arrow-up-tray">
|
<flux:icon.map-pin class="w-12 h-12 text-zinc-400 mx-auto mb-2" />
|
||||||
{{ __('CSV importieren') }}
|
<p class="text-zinc-600 dark:text-zinc-400">
|
||||||
</flux:button>
|
{{ __('Noch keine PLZs zugeordnet') }}
|
||||||
</div>
|
</p>
|
||||||
@endif
|
</flux:table.cell>
|
||||||
</div>
|
</flux:table.row>
|
||||||
</flux:card>
|
@endforelse
|
||||||
|
</flux:table.rows>
|
||||||
|
</flux:table>
|
||||||
|
|
||||||
{{-- PLZ-Liste --}}
|
{{-- Pagination --}}
|
||||||
<flux:card>
|
@if ($this->locations->hasPages())
|
||||||
<div class="p-6">
|
<div class="mt-4 border-t border-zinc-200 dark:border-zinc-700 pt-4">
|
||||||
<div class="flex items-center justify-between mb-4">
|
{{ $this->locations->links() }}
|
||||||
<flux:heading size="lg">
|
</div>
|
||||||
{{ __('Zugeordnete Postleitzahlen') }}
|
|
||||||
@if($hubId)
|
|
||||||
<span class="text-sm font-normal text-zinc-600 dark:text-zinc-400">
|
|
||||||
({{ $this->locations->total() }} {{ __('gesamt') }})
|
|
||||||
</span>
|
|
||||||
@endif
|
@endif
|
||||||
</flux:heading>
|
@else
|
||||||
<flux:input
|
<div class="text-center py-8 text-zinc-600 dark:text-zinc-400">
|
||||||
wire:model.live.debounce="zipSearch"
|
{{ __('Speichern Sie zuerst den Hub, um PLZs hinzuzufügen') }}
|
||||||
placeholder="{{ __('PLZ oder Stadt suchen...') }}"
|
</div>
|
||||||
icon="magnifying-glass"
|
@endif
|
||||||
class="w-64"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
</flux:card>
|
||||||
@if($hubId)
|
</div>
|
||||||
<flux:table>
|
|
||||||
<flux:table.columns>
|
|
||||||
<flux:table.column>{{ __('PLZ') }}</flux:table.column>
|
|
||||||
<flux:table.column>{{ __('Stadt') }}</flux:table.column>
|
|
||||||
<flux:table.column class="text-right w-32">{{ __('Aktion') }}</flux:table.column>
|
|
||||||
</flux:table.columns>
|
|
||||||
|
|
||||||
<flux:table.rows>
|
|
||||||
@forelse($this->locations as $location)
|
|
||||||
<flux:table.row :key="$location->id">
|
|
||||||
<flux:table.cell>
|
|
||||||
<span class="font-mono">{{ $location->zip_code }}</span>
|
|
||||||
</flux:table.cell>
|
|
||||||
<flux:table.cell>{{ $location->city_name }}</flux:table.cell>
|
|
||||||
<flux:table.cell class="text-right">
|
|
||||||
<flux:button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
icon="trash"
|
|
||||||
wire:click="deleteLocation({{ $location->id }})"
|
|
||||||
/>
|
|
||||||
</flux:table.cell>
|
|
||||||
</flux:table.row>
|
|
||||||
@empty
|
|
||||||
<flux:table.row>
|
|
||||||
<flux:table.cell colspan="3" class="text-center py-8">
|
|
||||||
<flux:icon.map-pin class="w-12 h-12 text-zinc-400 mx-auto mb-2" />
|
|
||||||
<p class="text-zinc-600 dark:text-zinc-400">
|
|
||||||
{{ __('Noch keine PLZs zugeordnet') }}
|
|
||||||
</p>
|
|
||||||
</flux:table.cell>
|
|
||||||
</flux:table.row>
|
|
||||||
@endforelse
|
|
||||||
</flux:table.rows>
|
|
||||||
</flux:table>
|
|
||||||
|
|
||||||
{{-- Pagination --}}
|
|
||||||
@if($this->locations->hasPages())
|
|
||||||
<div class="mt-4 border-t border-zinc-200 dark:border-zinc-700 pt-4">
|
|
||||||
{{ $this->locations->links() }}
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
@else
|
|
||||||
<div class="text-center py-8 text-zinc-600 dark:text-zinc-400">
|
|
||||||
{{ __('Speichern Sie zuerst den Hub, um PLZs hinzuzufügen') }}
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
</flux:card>
|
|
||||||
</div>
|
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
{{-- TAB 3: Partner-Monitor --}}
|
{{-- TAB 3: Partner-Monitor --}}
|
||||||
@if($activeTab === 'partners')
|
@if ($activeTab === 'partners')
|
||||||
<flux:card class="p-6">
|
<flux:card class="p-6">
|
||||||
<flux:heading size="lg" class="mb-4">
|
<flux:heading size="lg" class="mb-4">
|
||||||
{{ __('Partner in diesem Hub') }}
|
{{ __('Partner in diesem Hub') }}
|
||||||
@if($hubId)
|
@if ($hubId)
|
||||||
<span class="text-sm font-normal text-zinc-600 dark:text-zinc-400">
|
<span class="text-sm font-normal text-zinc-600 dark:text-zinc-400">
|
||||||
({{ $this->partners->count() }} {{ __('Partner') }})
|
({{ $this->partners->count() }} {{ __('Partner') }})
|
||||||
</span>
|
</span>
|
||||||
|
@endif
|
||||||
|
</flux:heading>
|
||||||
|
|
||||||
|
{{-- Info --}}
|
||||||
|
<flux:card class="p-4 mb-4 bg-zinc-50 dark:bg-zinc-800 border-0">
|
||||||
|
<div class="text-sm text-zinc-600 dark:text-zinc-400">
|
||||||
|
<strong>{{ __('Logik:') }}</strong>
|
||||||
|
<ul class="list-disc ml-5 mt-2 space-y-1">
|
||||||
|
<li>{{ __('Händler (Retailer) → Werden diesem Hub fest zugeordnet') }}</li>
|
||||||
|
<li>{{ __('Hersteller (Manufacturer) → Sind "Hub-agnostisch", in allen Hubs sichtbar') }}</li>
|
||||||
|
<li>{{ __('Makler (Estate-Agent) → Werden Hub zugeordnet für regionale Kundenakquise') }}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</flux:card>
|
||||||
|
|
||||||
|
@if ($hubId)
|
||||||
|
<flux:table>
|
||||||
|
<flux:table.columns>
|
||||||
|
<flux:table.column>{{ __('Name') }}</flux:table.column>
|
||||||
|
<flux:table.column>{{ __('Typ') }}</flux:table.column>
|
||||||
|
<flux:table.column>{{ __('Stadt') }}</flux:table.column>
|
||||||
|
<flux:table.column>{{ __('Lieferradius') }}</flux:table.column>
|
||||||
|
<flux:table.column class="text-center">{{ __('Status') }}</flux:table.column>
|
||||||
|
<flux:table.column class="text-right">{{ __('Aktion') }}</flux:table.column>
|
||||||
|
</flux:table.columns>
|
||||||
|
|
||||||
|
<flux:table.rows>
|
||||||
|
@forelse($this->partners as $partner)
|
||||||
|
<flux:table.row :key="$partner->id">
|
||||||
|
<flux:table.cell>
|
||||||
|
<div class="font-semibold text-zinc-900 dark:text-zinc-100">
|
||||||
|
{{ $partner->company_name }}
|
||||||
|
</div>
|
||||||
|
@if ($partner->display_name && $partner->display_name !== $partner->company_name)
|
||||||
|
<div class="text-xs text-zinc-500">{{ $partner->display_name }}</div>
|
||||||
|
@endif
|
||||||
|
</flux:table.cell>
|
||||||
|
<flux:table.cell>
|
||||||
|
@php
|
||||||
|
$typeColors = [
|
||||||
|
'Retailer' => 'blue',
|
||||||
|
'Manufacturer' => 'purple',
|
||||||
|
'Estate-Agent' => 'green',
|
||||||
|
];
|
||||||
|
@endphp
|
||||||
|
<flux:badge :color="$typeColors[$partner->type] ?? 'zinc'" size="sm">
|
||||||
|
{{ $partner->type }}
|
||||||
|
</flux:badge>
|
||||||
|
</flux:table.cell>
|
||||||
|
<flux:table.cell>
|
||||||
|
{{ $partner->city ?? '-' }}
|
||||||
|
</flux:table.cell>
|
||||||
|
<flux:table.cell>
|
||||||
|
@if ($partner->delivery_radius_km)
|
||||||
|
<span class="text-sm">{{ $partner->delivery_radius_km }} km</span>
|
||||||
|
@else
|
||||||
|
<span class="text-sm text-zinc-400">-</span>
|
||||||
|
@endif
|
||||||
|
</flux:table.cell>
|
||||||
|
<flux:table.cell class="text-center">
|
||||||
|
<flux:badge :color="$partner->is_active ? 'green' : 'zinc'" size="sm">
|
||||||
|
{{ $partner->is_active ? __('Aktiv') : __('Inaktiv') }}
|
||||||
|
</flux:badge>
|
||||||
|
</flux:table.cell>
|
||||||
|
<flux:table.cell class="text-right">
|
||||||
|
<flux:button variant="ghost" size="sm" icon="eye">
|
||||||
|
{{ __('Details') }}
|
||||||
|
</flux:button>
|
||||||
|
</flux:table.cell>
|
||||||
|
</flux:table.row>
|
||||||
|
@empty
|
||||||
|
<flux:table.row>
|
||||||
|
<flux:table.cell colspan="6" class="text-center py-8">
|
||||||
|
<flux:icon.user-group class="w-12 h-12 text-zinc-400 mx-auto mb-2" />
|
||||||
|
<p class="text-zinc-600 dark:text-zinc-400">
|
||||||
|
{{ __('Noch keine Partner in diesem Hub') }}
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-zinc-500 mt-2">
|
||||||
|
{{ __('Partner werden automatisch beim Onboarding zugeordnet (basierend auf PLZ)') }}
|
||||||
|
</p>
|
||||||
|
</flux:table.cell>
|
||||||
|
</flux:table.row>
|
||||||
|
@endforelse
|
||||||
|
</flux:table.rows>
|
||||||
|
</flux:table>
|
||||||
|
@else
|
||||||
|
<div class="text-center py-8 text-zinc-600 dark:text-zinc-400">
|
||||||
|
{{ __('Speichern Sie zuerst den Hub, um Partner-Zuordnungen zu sehen') }}
|
||||||
|
</div>
|
||||||
@endif
|
@endif
|
||||||
</flux:heading>
|
|
||||||
|
|
||||||
{{-- Info --}}
|
|
||||||
<flux:card class="p-4 mb-4 bg-zinc-50 dark:bg-zinc-800 border-0">
|
|
||||||
<div class="text-sm text-zinc-600 dark:text-zinc-400">
|
|
||||||
<strong>{{ __('Logik:') }}</strong>
|
|
||||||
<ul class="list-disc ml-5 mt-2 space-y-1">
|
|
||||||
<li>{{ __('Händler (Retailer) → Werden diesem Hub fest zugeordnet') }}</li>
|
|
||||||
<li>{{ __('Hersteller (Manufacturer) → Sind "Hub-agnostisch", in allen Hubs sichtbar') }}</li>
|
|
||||||
<li>{{ __('Makler (Estate-Agent) → Werden Hub zugeordnet für regionale Kundenakquise') }}</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</flux:card>
|
</flux:card>
|
||||||
|
|
||||||
@if($hubId)
|
|
||||||
<flux:table>
|
|
||||||
<flux:table.columns>
|
|
||||||
<flux:table.column>{{ __('Name') }}</flux:table.column>
|
|
||||||
<flux:table.column>{{ __('Typ') }}</flux:table.column>
|
|
||||||
<flux:table.column>{{ __('Stadt') }}</flux:table.column>
|
|
||||||
<flux:table.column>{{ __('Lieferradius') }}</flux:table.column>
|
|
||||||
<flux:table.column class="text-center">{{ __('Status') }}</flux:table.column>
|
|
||||||
<flux:table.column class="text-right">{{ __('Aktion') }}</flux:table.column>
|
|
||||||
</flux:table.columns>
|
|
||||||
|
|
||||||
<flux:table.rows>
|
|
||||||
@forelse($this->partners as $partner)
|
|
||||||
<flux:table.row :key="$partner->id">
|
|
||||||
<flux:table.cell>
|
|
||||||
<div class="font-semibold text-zinc-900 dark:text-zinc-100">
|
|
||||||
{{ $partner->company_name }}
|
|
||||||
</div>
|
|
||||||
@if($partner->display_name && $partner->display_name !== $partner->company_name)
|
|
||||||
<div class="text-xs text-zinc-500">{{ $partner->display_name }}</div>
|
|
||||||
@endif
|
|
||||||
</flux:table.cell>
|
|
||||||
<flux:table.cell>
|
|
||||||
@php
|
|
||||||
$typeColors = [
|
|
||||||
'Retailer' => 'blue',
|
|
||||||
'Manufacturer' => 'purple',
|
|
||||||
'Estate-Agent' => 'green',
|
|
||||||
];
|
|
||||||
@endphp
|
|
||||||
<flux:badge :color="$typeColors[$partner->type] ?? 'zinc'" size="sm">
|
|
||||||
{{ $partner->type }}
|
|
||||||
</flux:badge>
|
|
||||||
</flux:table.cell>
|
|
||||||
<flux:table.cell>
|
|
||||||
{{ $partner->city ?? '-' }}
|
|
||||||
</flux:table.cell>
|
|
||||||
<flux:table.cell>
|
|
||||||
@if($partner->delivery_radius_km)
|
|
||||||
<span class="text-sm">{{ $partner->delivery_radius_km }} km</span>
|
|
||||||
@else
|
|
||||||
<span class="text-sm text-zinc-400">-</span>
|
|
||||||
@endif
|
|
||||||
</flux:table.cell>
|
|
||||||
<flux:table.cell class="text-center">
|
|
||||||
<flux:badge :color="$partner->is_active ? 'green' : 'zinc'" size="sm">
|
|
||||||
{{ $partner->is_active ? __('Aktiv') : __('Inaktiv') }}
|
|
||||||
</flux:badge>
|
|
||||||
</flux:table.cell>
|
|
||||||
<flux:table.cell class="text-right">
|
|
||||||
<flux:button variant="ghost" size="sm" icon="eye">
|
|
||||||
{{ __('Details') }}
|
|
||||||
</flux:button>
|
|
||||||
</flux:table.cell>
|
|
||||||
</flux:table.row>
|
|
||||||
@empty
|
|
||||||
<flux:table.row>
|
|
||||||
<flux:table.cell colspan="6" class="text-center py-8">
|
|
||||||
<flux:icon.user-group class="w-12 h-12 text-zinc-400 mx-auto mb-2" />
|
|
||||||
<p class="text-zinc-600 dark:text-zinc-400">
|
|
||||||
{{ __('Noch keine Partner in diesem Hub') }}
|
|
||||||
</p>
|
|
||||||
<p class="text-xs text-zinc-500 mt-2">
|
|
||||||
{{ __('Partner werden automatisch beim Onboarding zugeordnet (basierend auf PLZ)') }}
|
|
||||||
</p>
|
|
||||||
</flux:table.cell>
|
|
||||||
</flux:table.row>
|
|
||||||
@endforelse
|
|
||||||
</flux:table.rows>
|
|
||||||
</flux:table>
|
|
||||||
@else
|
|
||||||
<div class="text-center py-8 text-zinc-600 dark:text-zinc-400">
|
|
||||||
{{ __('Speichern Sie zuerst den Hub, um Partner-Zuordnungen zu sehen') }}
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
</flux:card>
|
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -272,16 +272,16 @@ new class extends Component
|
||||||
$rules['deliveryRadius'] = 'required|integer|min:1|max:500';
|
$rules['deliveryRadius'] = 'required|integer|min:1|max:500';
|
||||||
$rules['assemblyRadius'] = 'required|integer|min:1|max:500';
|
$rules['assemblyRadius'] = 'required|integer|min:1|max:500';
|
||||||
$rules['newTeamPhotos'] = 'nullable|array|max:10';
|
$rules['newTeamPhotos'] = 'nullable|array|max:10';
|
||||||
$rules['newTeamPhotos.*'] = 'image|mimes:jpeg,jpg,png|max:10240';
|
$rules['newTeamPhotos.*'] = 'image|mimes:jpeg,jpg,png|max:204800';
|
||||||
$rules['newShowroomPhotos'] = 'nullable|array|max:20';
|
$rules['newShowroomPhotos'] = 'nullable|array|max:20';
|
||||||
$rules['newShowroomPhotos.*'] = 'image|mimes:jpeg,jpg,png|max:10240';
|
$rules['newShowroomPhotos.*'] = 'image|mimes:jpeg,jpg,png|max:204800';
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($this->isManufacturer()) {
|
if ($this->isManufacturer()) {
|
||||||
$rules['brandName'] = 'required|string|max:255';
|
$rules['brandName'] = 'required|string|max:255';
|
||||||
$rules['brandDescription'] = 'nullable|string|max:1000';
|
$rules['brandDescription'] = 'nullable|string|max:1000';
|
||||||
$rules['newBrandImages'] = 'nullable|array|max:10';
|
$rules['newBrandImages'] = 'nullable|array|max:10';
|
||||||
$rules['newBrandImages.*'] = 'image|mimes:jpeg,jpg,png|max:10240';
|
$rules['newBrandImages.*'] = 'image|mimes:jpeg,jpg,png|max:204800';
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->validate($rules, [
|
$this->validate($rules, [
|
||||||
|
|
@ -305,11 +305,11 @@ new class extends Component
|
||||||
'foundedYear.min' => __('Das Gründungsjahr muss nach 1800 liegen.'),
|
'foundedYear.min' => __('Das Gründungsjahr muss nach 1800 liegen.'),
|
||||||
'foundedYear.max' => __('Das Gründungsjahr darf nicht in der Zukunft liegen.'),
|
'foundedYear.max' => __('Das Gründungsjahr darf nicht in der Zukunft liegen.'),
|
||||||
'newTeamPhotos.*.image' => __('Nur Bilder (JPG, PNG) erlaubt.'),
|
'newTeamPhotos.*.image' => __('Nur Bilder (JPG, PNG) erlaubt.'),
|
||||||
'newTeamPhotos.*.max' => __('Bilder dürfen maximal 10 MB groß sein.'),
|
'newTeamPhotos.*.max' => __('Bilder dürfen maximal 200 MB groß sein.'),
|
||||||
'newShowroomPhotos.*.image' => __('Nur Bilder (JPG, PNG) erlaubt.'),
|
'newShowroomPhotos.*.image' => __('Nur Bilder (JPG, PNG) erlaubt.'),
|
||||||
'newShowroomPhotos.*.max' => __('Bilder dürfen maximal 10 MB groß sein.'),
|
'newShowroomPhotos.*.max' => __('Bilder dürfen maximal 200 MB groß sein.'),
|
||||||
'newBrandImages.*.image' => __('Nur Bilder (JPG, PNG) erlaubt.'),
|
'newBrandImages.*.image' => __('Nur Bilder (JPG, PNG) erlaubt.'),
|
||||||
'newBrandImages.*.max' => __('Bilder dürfen maximal 10 MB groß sein.'),
|
'newBrandImages.*.max' => __('Bilder dürfen maximal 200 MB groß sein.'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$specialties = array_values(array_filter(
|
$specialties = array_values(array_filter(
|
||||||
|
|
@ -793,7 +793,7 @@ new class extends Component
|
||||||
<flux:card class="shadow-elegant">
|
<flux:card class="shadow-elegant">
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<flux:heading size="lg">{{ __('Team-Fotos') }}</flux:heading>
|
<flux:heading size="lg">{{ __('Team-Fotos') }}</flux:heading>
|
||||||
<flux:subheading>{{ __('Nur JPG/PNG – max. 10 MB pro Bild') }}</flux:subheading>
|
<flux:subheading>{{ __('Nur JPG/PNG – max. 200 MB pro Bild') }}</flux:subheading>
|
||||||
</div>
|
</div>
|
||||||
<flux:separator class="mb-6" />
|
<flux:separator class="mb-6" />
|
||||||
|
|
||||||
|
|
@ -867,7 +867,7 @@ new class extends Component
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
<flux:file-upload wire:model="newTeamPhotos" multiple accept="image/jpeg,image/png,.jpg,.jpeg,.png">
|
<flux:file-upload wire:model="newTeamPhotos" multiple accept="image/jpeg,image/png,.jpg,.jpeg,.png">
|
||||||
<flux:file-upload.dropzone heading="{{ __('Team-Fotos hochladen') }}" text="{{ __('JPEG oder PNG – max. 10 MB') }}" with-progress />
|
<flux:file-upload.dropzone heading="{{ __('Team-Fotos hochladen') }}" text="{{ __('JPEG oder PNG – max. 200 MB') }}" with-progress />
|
||||||
</flux:file-upload>
|
</flux:file-upload>
|
||||||
|
|
||||||
@if (count($newTeamPhotos) > 0)
|
@if (count($newTeamPhotos) > 0)
|
||||||
|
|
@ -892,7 +892,7 @@ new class extends Component
|
||||||
<flux:card class="shadow-elegant">
|
<flux:card class="shadow-elegant">
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<flux:heading size="lg">{{ __('Showroom-Galerie') }}</flux:heading>
|
<flux:heading size="lg">{{ __('Showroom-Galerie') }}</flux:heading>
|
||||||
<flux:subheading>{{ __('Bilder Ihres Showrooms für das öffentliche Profil – nur JPG/PNG, max. 10 MB') }}</flux:subheading>
|
<flux:subheading>{{ __('Bilder Ihres Showrooms für das öffentliche Profil – nur JPG/PNG, max. 200 MB') }}</flux:subheading>
|
||||||
</div>
|
</div>
|
||||||
<flux:separator class="mb-6" />
|
<flux:separator class="mb-6" />
|
||||||
|
|
||||||
|
|
@ -966,7 +966,7 @@ new class extends Component
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
<flux:file-upload wire:model="newShowroomPhotos" multiple accept="image/jpeg,image/png,.jpg,.jpeg,.png">
|
<flux:file-upload wire:model="newShowroomPhotos" multiple accept="image/jpeg,image/png,.jpg,.jpeg,.png">
|
||||||
<flux:file-upload.dropzone heading="{{ __('Showroom-Bilder hochladen') }}" text="{{ __('JPEG oder PNG – max. 10 MB') }}" with-progress />
|
<flux:file-upload.dropzone heading="{{ __('Showroom-Bilder hochladen') }}" text="{{ __('JPEG oder PNG – max. 200 MB') }}" with-progress />
|
||||||
</flux:file-upload>
|
</flux:file-upload>
|
||||||
|
|
||||||
@if (count($newShowroomPhotos) > 0)
|
@if (count($newShowroomPhotos) > 0)
|
||||||
|
|
@ -993,7 +993,7 @@ new class extends Component
|
||||||
<flux:card class="shadow-elegant">
|
<flux:card class="shadow-elegant">
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<flux:heading size="lg">{{ __('Marken-Bilder') }}</flux:heading>
|
<flux:heading size="lg">{{ __('Marken-Bilder') }}</flux:heading>
|
||||||
<flux:subheading>{{ __('Bilder für Ihre Marken-Präsentation (Atmosphäre, Brand-Story etc.) – nur JPG/PNG, max. 10 MB') }}</flux:subheading>
|
<flux:subheading>{{ __('Bilder für Ihre Marken-Präsentation (Atmosphäre, Brand-Story etc.) – nur JPG/PNG, max. 200 MB') }}</flux:subheading>
|
||||||
</div>
|
</div>
|
||||||
<flux:separator class="mb-6" />
|
<flux:separator class="mb-6" />
|
||||||
|
|
||||||
|
|
@ -1067,7 +1067,7 @@ new class extends Component
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
<flux:file-upload wire:model="newBrandImages" multiple accept="image/jpeg,image/png,.jpg,.jpeg,.png">
|
<flux:file-upload wire:model="newBrandImages" multiple accept="image/jpeg,image/png,.jpg,.jpeg,.png">
|
||||||
<flux:file-upload.dropzone heading="{{ __('Marken-Bilder hochladen') }}" text="{{ __('JPEG oder PNG – max. 10 MB') }}" with-progress />
|
<flux:file-upload.dropzone heading="{{ __('Marken-Bilder hochladen') }}" text="{{ __('JPEG oder PNG – max. 200 MB') }}" with-progress />
|
||||||
</flux:file-upload>
|
</flux:file-upload>
|
||||||
|
|
||||||
@if (count($newBrandImages) > 0)
|
@if (count($newBrandImages) > 0)
|
||||||
|
|
|
||||||
|
|
@ -461,7 +461,7 @@ new class extends Component
|
||||||
'status' => 'required|in:active,draft',
|
'status' => 'required|in:active,draft',
|
||||||
// Bilder
|
// Bilder
|
||||||
'mainImages' => 'nullable|array|min:0|max:10',
|
'mainImages' => 'nullable|array|min:0|max:10',
|
||||||
'mainImages.*' => 'mimes:jpeg,jpg,png|max:10240',
|
'mainImages.*' => 'mimes:jpeg,jpg,png|max:204800',
|
||||||
// Maße & Material
|
// Maße & Material
|
||||||
'widthCm' => 'nullable|integer|min:1',
|
'widthCm' => 'nullable|integer|min:1',
|
||||||
'heightCm' => 'nullable|integer|min:1',
|
'heightCm' => 'nullable|integer|min:1',
|
||||||
|
|
@ -546,7 +546,7 @@ new class extends Component
|
||||||
'priceDisplayText.required_if' => __('Bei Ab-Preis ist eine Preisangabe erforderlich (z.B. "Ab 2.500 €").'),
|
'priceDisplayText.required_if' => __('Bei Ab-Preis ist eine Preisangabe erforderlich (z.B. "Ab 2.500 €").'),
|
||||||
'mainImages.max' => __('Maximal 10 Produktbilder erlaubt.'),
|
'mainImages.max' => __('Maximal 10 Produktbilder erlaubt.'),
|
||||||
'mainImages.*.mimes' => __('Nur Bilder (JPG, JPEG, PNG) erlaubt.'),
|
'mainImages.*.mimes' => __('Nur Bilder (JPG, JPEG, PNG) erlaubt.'),
|
||||||
'mainImages.*.max' => __('Bilder dürfen maximal 10 MB groß sein.'),
|
'mainImages.*.max' => __('Bilder dürfen maximal 200 MB groß sein.'),
|
||||||
'sku.unique' => __('Diese Artikelnummer ist bereits vergeben.'),
|
'sku.unique' => __('Diese Artikelnummer ist bereits vergeben.'),
|
||||||
'sellingPrice.min' => __('Der Verkaufspreis muss größer als 0 sein.'),
|
'sellingPrice.min' => __('Der Verkaufspreis muss größer als 0 sein.'),
|
||||||
'countryOfOrigin.size' => __('Bitte geben Sie einen gültigen 2-stelligen ISO-Ländercode ein (z.B. DE).'),
|
'countryOfOrigin.size' => __('Bitte geben Sie einen gültigen 2-stelligen ISO-Ländercode ein (z.B. DE).'),
|
||||||
|
|
@ -1229,7 +1229,7 @@ new class extends Component
|
||||||
<flux:card class="shadow-elegant">
|
<flux:card class="shadow-elegant">
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<flux:heading size="lg">{{ __('Produktbilder') }}</flux:heading>
|
<flux:heading size="lg">{{ __('Produktbilder') }}</flux:heading>
|
||||||
<flux:subheading>{{ __('Nur Bilder (JPG, JPEG, PNG) – max. 10 MB pro Bild, max. 10 Bilder') }}
|
<flux:subheading>{{ __('Nur Bilder (JPG, JPEG, PNG) – max. 200 MB pro Bild, max. 10 Bilder') }}
|
||||||
</flux:subheading>
|
</flux:subheading>
|
||||||
</div>
|
</div>
|
||||||
<flux:separator class="mb-6" />
|
<flux:separator class="mb-6" />
|
||||||
|
|
@ -1309,7 +1309,7 @@ new class extends Component
|
||||||
<flux:file-upload wire:model="mainImages" label="Upload files" multiple
|
<flux:file-upload wire:model="mainImages" label="Upload files" multiple
|
||||||
accept="image/jpeg,image/png,.jpg,.jpeg,.png">
|
accept="image/jpeg,image/png,.jpg,.jpeg,.png">
|
||||||
<flux:file-upload.dropzone heading="{{ __('Bilder hochladen') }}"
|
<flux:file-upload.dropzone heading="{{ __('Bilder hochladen') }}"
|
||||||
text="{{ __('Nur JPEG oder PNG – max. 10 MB') }}" with-progress />
|
text="{{ __('Nur JPEG oder PNG – max. 200 MB') }}" with-progress />
|
||||||
</flux:file-upload>
|
</flux:file-upload>
|
||||||
|
|
||||||
@if (isset($mainImages) && count($mainImages) > 0)
|
@if (isset($mainImages) && count($mainImages) > 0)
|
||||||
|
|
|
||||||
|
|
@ -238,7 +238,7 @@ new class extends Component
|
||||||
'status' => 'required|in:active,draft',
|
'status' => 'required|in:active,draft',
|
||||||
'partnerProductNumber' => 'nullable|string|max:100',
|
'partnerProductNumber' => 'nullable|string|max:100',
|
||||||
'mainImages' => 'nullable|array|min:0|max:10',
|
'mainImages' => 'nullable|array|min:0|max:10',
|
||||||
'mainImages.*' => 'mimes:jpeg,jpg,png|max:10240',
|
'mainImages.*' => 'mimes:jpeg,jpg,png|max:204800',
|
||||||
];
|
];
|
||||||
|
|
||||||
$messages = [
|
$messages = [
|
||||||
|
|
@ -250,7 +250,7 @@ new class extends Component
|
||||||
'categoryId.required' => __('Bitte wählen Sie eine Kategorie aus.'),
|
'categoryId.required' => __('Bitte wählen Sie eine Kategorie aus.'),
|
||||||
'mainImages.max' => __('Maximal 10 Produktbilder erlaubt.'),
|
'mainImages.max' => __('Maximal 10 Produktbilder erlaubt.'),
|
||||||
'mainImages.*.mimes' => __('Nur Bilder (JPG, JPEG, PNG) erlaubt.'),
|
'mainImages.*.mimes' => __('Nur Bilder (JPG, JPEG, PNG) erlaubt.'),
|
||||||
'mainImages.*.max' => __('Bilder dürfen maximal 10 MB groß sein.'),
|
'mainImages.*.max' => __('Bilder dürfen maximal 200 MB groß sein.'),
|
||||||
];
|
];
|
||||||
|
|
||||||
if ($isAdminWithoutPartner) {
|
if ($isAdminWithoutPartner) {
|
||||||
|
|
@ -459,7 +459,7 @@ new class extends Component
|
||||||
<flux:card class="shadow-elegant">
|
<flux:card class="shadow-elegant">
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<flux:heading size="lg">{{ $isEditing ? __('Produktbilder') : __('Produktbild') }}</flux:heading>
|
<flux:heading size="lg">{{ $isEditing ? __('Produktbilder') : __('Produktbild') }}</flux:heading>
|
||||||
<flux:subheading>{{ __('Nur Bilder (JPG, JPEG, PNG) – max. 10 MB pro Bild, max. 10 Bilder') }}
|
<flux:subheading>{{ __('Nur Bilder (JPG, JPEG, PNG) – max. 200 MB pro Bild, max. 10 Bilder') }}
|
||||||
</flux:subheading>
|
</flux:subheading>
|
||||||
</div>
|
</div>
|
||||||
<flux:separator class="mb-6" />
|
<flux:separator class="mb-6" />
|
||||||
|
|
@ -539,7 +539,7 @@ new class extends Component
|
||||||
<flux:file-upload wire:model="mainImages" label="Upload files" multiple
|
<flux:file-upload wire:model="mainImages" label="Upload files" multiple
|
||||||
accept="image/jpeg,image/png,.jpg,.jpeg,.png">
|
accept="image/jpeg,image/png,.jpg,.jpeg,.png">
|
||||||
<flux:file-upload.dropzone heading="{{ __('Bilder hochladen') }}"
|
<flux:file-upload.dropzone heading="{{ __('Bilder hochladen') }}"
|
||||||
text="{{ __('Nur JPEG oder PNG – max. 10 MB') }}" with-progress />
|
text="{{ __('Nur JPEG oder PNG – max. 200 MB') }}" with-progress />
|
||||||
</flux:file-upload>
|
</flux:file-upload>
|
||||||
|
|
||||||
@if (isset($mainImages) && count($mainImages) > 0)
|
@if (isset($mainImages) && count($mainImages) > 0)
|
||||||
|
|
|
||||||
|
|
@ -66,10 +66,6 @@ Route::middleware(['auth', 'partner.setup'])->group(function () {
|
||||||
// Display CMS
|
// Display CMS
|
||||||
Volt::route('admin/cms/display-dashboard', 'admin.cms.display-dashboard')->name('admin.cms.display-dashboard');
|
Volt::route('admin/cms/display-dashboard', 'admin.cms.display-dashboard')->name('admin.cms.display-dashboard');
|
||||||
Volt::route('admin/cms/display-media', 'admin.cms.display-media-library')->name('admin.cms.display-media');
|
Volt::route('admin/cms/display-media', 'admin.cms.display-media-library')->name('admin.cms.display-media');
|
||||||
Route::redirect('admin/cms/display-versions', 'admin/cms/display-modules', 301)->name('admin.cms.display-versions');
|
|
||||||
Route::get('admin/cms/display-versions/{displayVersion}/edit', function (\App\Models\DisplayVersion $displayVersion) {
|
|
||||||
return redirect()->route('admin.cms.display-module-edit', $displayVersion, 301);
|
|
||||||
})->name('admin.cms.display-version-edit');
|
|
||||||
Route::get('admin/cms/display-modules', \App\Livewire\Admin\Cms\DisplayVersionList::class)->name('admin.cms.display-modules');
|
Route::get('admin/cms/display-modules', \App\Livewire\Admin\Cms\DisplayVersionList::class)->name('admin.cms.display-modules');
|
||||||
Route::get('admin/cms/display-modules/{displayVersion}/edit', \App\Livewire\Admin\Cms\DisplayVersionEditor::class)->name('admin.cms.display-module-edit');
|
Route::get('admin/cms/display-modules/{displayVersion}/edit', \App\Livewire\Admin\Cms\DisplayVersionEditor::class)->name('admin.cms.display-module-edit');
|
||||||
Route::get('admin/cms/displays', \App\Livewire\Admin\Cms\DisplayList::class)->name('admin.cms.displays');
|
Route::get('admin/cms/displays', \App\Livewire\Admin\Cms\DisplayList::class)->name('admin.cms.displays');
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@ Route::domain($domainPortal)->group(function () {
|
||||||
Route::get('/api/cabinet-tablet/check', [\App\Http\Controllers\Api\CabinetTabletController::class, 'check']);
|
Route::get('/api/cabinet-tablet/check', [\App\Http\Controllers\Api\CabinetTabletController::class, 'check']);
|
||||||
|
|
||||||
// Display Version API (per physical display)
|
// Display Version API (per physical display)
|
||||||
|
Route::get('/api/display/overview', [\App\Http\Controllers\Api\DisplayVersionApiController::class, 'overview']);
|
||||||
Route::get('/api/display/{display}/config', [\App\Http\Controllers\Api\DisplayVersionApiController::class, 'config']);
|
Route::get('/api/display/{display}/config', [\App\Http\Controllers\Api\DisplayVersionApiController::class, 'config']);
|
||||||
Route::get('/api/display/{display}/check', [\App\Http\Controllers\Api\DisplayVersionApiController::class, 'check']);
|
Route::get('/api/display/{display}/check', [\App\Http\Controllers\Api\DisplayVersionApiController::class, 'check']);
|
||||||
Route::get('/api/display/preview/{token}', [\App\Http\Controllers\Api\DisplayPreviewController::class, 'config']);
|
Route::get('/api/display/preview/{token}', [\App\Http\Controllers\Api\DisplayPreviewController::class, 'config']);
|
||||||
|
|
@ -127,6 +128,7 @@ Route::get('/api/cabinet-tablet/status', [\App\Http\Controllers\Api\CabinetTable
|
||||||
Route::get('/api/cabinet-tablet/check', [\App\Http\Controllers\Api\CabinetTabletController::class, 'check']);
|
Route::get('/api/cabinet-tablet/check', [\App\Http\Controllers\Api\CabinetTabletController::class, 'check']);
|
||||||
|
|
||||||
// Fallback: Display Version API
|
// Fallback: Display Version API
|
||||||
|
Route::get('/api/display/overview', [\App\Http\Controllers\Api\DisplayVersionApiController::class, 'overview']);
|
||||||
Route::get('/api/display/{display}/config', [\App\Http\Controllers\Api\DisplayVersionApiController::class, 'config']);
|
Route::get('/api/display/{display}/config', [\App\Http\Controllers\Api\DisplayVersionApiController::class, 'config']);
|
||||||
Route::get('/api/display/{display}/check', [\App\Http\Controllers\Api\DisplayVersionApiController::class, 'check']);
|
Route::get('/api/display/{display}/check', [\App\Http\Controllers\Api\DisplayVersionApiController::class, 'check']);
|
||||||
Route::get('/api/display/preview/{token}', [\App\Http\Controllers\Api\DisplayPreviewController::class, 'config']);
|
Route::get('/api/display/preview/{token}', [\App\Http\Controllers\Api\DisplayPreviewController::class, 'config']);
|
||||||
|
|
|
||||||
|
|
@ -29,3 +29,10 @@ test('media picker zeigt ausgewähltes medium ohne livewire property fehler', fu
|
||||||
->assertSee('test-image.jpg')
|
->assertSee('test-image.jpg')
|
||||||
->assertDontSee('Kein Medium ausgewählt');
|
->assertDontSee('Kein Medium ausgewählt');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('cms media picker und library uploader nutzen 200 mb dateigrenze', function () {
|
||||||
|
expect(file_get_contents(app_path('Livewire/Admin/Cms/MediaPicker.php')))
|
||||||
|
->toContain('max:204800')
|
||||||
|
->and(file_get_contents(app_path('Livewire/Admin/Cms/MediaLibraryUploader.php')))
|
||||||
|
->toContain('max:204800');
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -79,9 +79,6 @@ test('can assign versions to a display', function () {
|
||||||
->call('save');
|
->call('save');
|
||||||
|
|
||||||
$display->refresh();
|
$display->refresh();
|
||||||
expect($display->versions)->toHaveCount(2);
|
|
||||||
expect($display->versions->first()->id)->toBe($version1->id);
|
|
||||||
expect($display->versions->last()->id)->toBe($version2->id);
|
|
||||||
expect($display->livePlaylist->modules->pluck('id')->all())->toBe([$version1->id, $version2->id]);
|
expect($display->livePlaylist->modules->pluck('id')->all())->toBe([$version1->id, $version2->id]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -100,8 +97,6 @@ test('can reorder versions in playlist', function () {
|
||||||
->call('save');
|
->call('save');
|
||||||
|
|
||||||
$display->refresh();
|
$display->refresh();
|
||||||
expect($display->versions->first()->id)->toBe($version2->id);
|
|
||||||
expect($display->versions->last()->id)->toBe($version1->id);
|
|
||||||
expect($display->livePlaylist->modules->pluck('id')->all())->toBe([$version2->id, $version1->id]);
|
expect($display->livePlaylist->modules->pluck('id')->all())->toBe([$version2->id, $version1->id]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -110,10 +105,7 @@ test('can remove version from playlist', function () {
|
||||||
$version1 = DisplayVersion::factory()->create();
|
$version1 = DisplayVersion::factory()->create();
|
||||||
$version2 = DisplayVersion::factory()->create();
|
$version2 = DisplayVersion::factory()->create();
|
||||||
$display = Display::factory()->create();
|
$display = Display::factory()->create();
|
||||||
$display->versions()->attach([
|
createDisplayListPlaylist($display, DisplayPlaylist::STATUS_PUBLISHED, [$version1->id, $version2->id]);
|
||||||
$version1->id => ['sort_order' => 0],
|
|
||||||
$version2->id => ['sort_order' => 1],
|
|
||||||
]);
|
|
||||||
|
|
||||||
Livewire::actingAs($user)
|
Livewire::actingAs($user)
|
||||||
->test(DisplayList::class)
|
->test(DisplayList::class)
|
||||||
|
|
@ -122,8 +114,6 @@ test('can remove version from playlist', function () {
|
||||||
->call('save');
|
->call('save');
|
||||||
|
|
||||||
$display->refresh();
|
$display->refresh();
|
||||||
expect($display->versions)->toHaveCount(1);
|
|
||||||
expect($display->versions->first()->id)->toBe($version2->id);
|
|
||||||
expect($display->livePlaylist->modules->pluck('id')->all())->toBe([$version2->id]);
|
expect($display->livePlaylist->modules->pluck('id')->all())->toBe([$version2->id]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -207,7 +197,7 @@ test('can create a draft playlist from live modules', function () {
|
||||||
test('can discard a draft playlist', function () {
|
test('can discard a draft playlist', function () {
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
$version = DisplayVersion::factory()->create();
|
$version = DisplayVersion::factory()->create();
|
||||||
$display = Display::factory()->create();
|
$display = Display::factory()->create(['preview_token' => 'token-discard-123456789012345678901234']);
|
||||||
createDisplayListPlaylist($display, DisplayPlaylist::STATUS_DRAFT, [$version->id]);
|
createDisplayListPlaylist($display, DisplayPlaylist::STATUS_DRAFT, [$version->id]);
|
||||||
|
|
||||||
Livewire::actingAs($user)
|
Livewire::actingAs($user)
|
||||||
|
|
@ -215,6 +205,57 @@ test('can discard a draft playlist', function () {
|
||||||
->call('discardDraft', $display->id);
|
->call('discardDraft', $display->id);
|
||||||
|
|
||||||
expect($display->fresh()->draftPlaylist)->toBeNull();
|
expect($display->fresh()->draftPlaylist)->toBeNull();
|
||||||
|
expect($display->fresh()->preview_token)->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('publishing a draft clears the preview token', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$liveVersion = DisplayVersion::factory()->create();
|
||||||
|
$draftVersion = DisplayVersion::factory()->create();
|
||||||
|
$display = Display::factory()->create(['preview_token' => 'token-publish-12345678901234567890123456']);
|
||||||
|
createDisplayListPlaylist($display, DisplayPlaylist::STATUS_PUBLISHED, [$liveVersion->id]);
|
||||||
|
createDisplayListPlaylist($display, DisplayPlaylist::STATUS_DRAFT, [$draftVersion->id]);
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(DisplayList::class)
|
||||||
|
->call('publishDraft', $display->id);
|
||||||
|
|
||||||
|
expect($display->fresh()->preview_token)->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('can rotate the preview token of a draft', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$version = DisplayVersion::factory()->create();
|
||||||
|
$display = Display::factory()->create(['preview_token' => 'token-old-1234567890123456789012345678901']);
|
||||||
|
createDisplayListPlaylist($display, DisplayPlaylist::STATUS_DRAFT, [$version->id]);
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(DisplayList::class)
|
||||||
|
->call('rotatePreviewToken', $display->id);
|
||||||
|
|
||||||
|
$newToken = $display->fresh()->preview_token;
|
||||||
|
|
||||||
|
expect($newToken)->not->toBeNull()
|
||||||
|
->and($newToken)->not->toBe('token-old-1234567890123456789012345678901');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('can add multiple modules to a playlist at once', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$version1 = DisplayVersion::factory()->create();
|
||||||
|
$version2 = DisplayVersion::factory()->create();
|
||||||
|
$version3 = DisplayVersion::factory()->create();
|
||||||
|
$display = Display::factory()->create();
|
||||||
|
createDisplayListPlaylist($display, DisplayPlaylist::STATUS_PUBLISHED, []);
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(DisplayList::class)
|
||||||
|
->call('openModal', $display->id, DisplayPlaylist::STATUS_PUBLISHED)
|
||||||
|
->set('versionsToAdd', [$version1->id, $version3->id])
|
||||||
|
->call('addSelectedVersions')
|
||||||
|
->assertSet('selectedVersionIds', [$version1->id, $version3->id])
|
||||||
|
->assertSet('versionsToAdd', []);
|
||||||
|
|
||||||
|
expect(true)->toBeTrue();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('can publish a draft playlist over the live playlist', function () {
|
test('can publish a draft playlist over the live playlist', function () {
|
||||||
|
|
@ -233,7 +274,6 @@ test('can publish a draft playlist over the live playlist', function () {
|
||||||
|
|
||||||
expect($display->draftPlaylist)->toBeNull();
|
expect($display->draftPlaylist)->toBeNull();
|
||||||
expect($display->livePlaylist->modules->pluck('id')->all())->toBe([$draftVersion->id]);
|
expect($display->livePlaylist->modules->pluck('id')->all())->toBe([$draftVersion->id]);
|
||||||
expect($display->versions->pluck('id')->all())->toBe([$draftVersion->id]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('can edit live playlist without changing draft playlist', function () {
|
test('can edit live playlist without changing draft playlist', function () {
|
||||||
|
|
@ -277,7 +317,6 @@ test('can edit draft playlist without changing live playlist', function () {
|
||||||
expect($display->preview_token)->not->toBeNull();
|
expect($display->preview_token)->not->toBeNull();
|
||||||
expect($display->livePlaylist->modules->pluck('id')->all())->toBe([$liveVersion->id]);
|
expect($display->livePlaylist->modules->pluck('id')->all())->toBe([$liveVersion->id]);
|
||||||
expect($display->draftPlaylist->modules->pluck('id')->all())->toBe([$draftVersion->id, $newDraftVersion->id]);
|
expect($display->draftPlaylist->modules->pluck('id')->all())->toBe([$draftVersion->id, $newDraftVersion->id]);
|
||||||
expect($display->versions->pluck('id')->all())->toBe([]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('draft editor renders iframe preview url', function () {
|
test('draft editor renders iframe preview url', function () {
|
||||||
|
|
@ -325,8 +364,8 @@ test('module select only shows modules that can still be added', function () {
|
||||||
Livewire::actingAs($user)
|
Livewire::actingAs($user)
|
||||||
->test(DisplayList::class)
|
->test(DisplayList::class)
|
||||||
->call('openModal', $display->id, DisplayPlaylist::STATUS_PUBLISHED)
|
->call('openModal', $display->id, DisplayPlaylist::STATUS_PUBLISHED)
|
||||||
->assertDontSeeHtml('<option value="'.$selectedVersion->id.'">Schon gewählt')
|
->assertDontSee('Schon gewählt (')
|
||||||
->assertSeeHtml('<option value="'.$availableVersion->id.'">Noch verfügbar');
|
->assertSee('Noch verfügbar (');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('module select is replaced by a hint when all modules are already selected', function () {
|
test('module select is replaced by a hint when all modules are already selected', function () {
|
||||||
|
|
@ -354,9 +393,26 @@ test('renders live and draft playlist columns', function () {
|
||||||
->test(DisplayList::class)
|
->test(DisplayList::class)
|
||||||
->assertSee('Live Modul')
|
->assertSee('Live Modul')
|
||||||
->assertSee('Draft Modul')
|
->assertSee('Draft Modul')
|
||||||
|
->assertSee('Öffentliche Display-Übersicht')
|
||||||
|
->assertSee('Display-Übersicht öffnen')
|
||||||
|
->assertSee('https://cabinet.b2in.eu/display/', false)
|
||||||
->assertSee('Live bearbeiten')
|
->assertSee('Live bearbeiten')
|
||||||
->assertSee('Live-URL zum Kopieren')
|
->assertSee('Live-URL zum Kopieren')
|
||||||
->assertSee(url('/_cabinet/display/index.html').'?id='.$display->id, false)
|
->assertSee('https://cabinet.b2in.eu/display/?id='.$display->id, false)
|
||||||
->assertSee('Entwurf bearbeiten')
|
->assertSee('Entwurf bearbeiten')
|
||||||
->assertSee('Test-Display');
|
->assertSee('Test-Display');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('display live urls fall back to cabinet domain when config is empty', function () {
|
||||||
|
config(['display.player_url' => '']);
|
||||||
|
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$version = DisplayVersion::factory()->create(['name' => 'Live Modul']);
|
||||||
|
$display = Display::factory()->create();
|
||||||
|
createDisplayListPlaylist($display, DisplayPlaylist::STATUS_PUBLISHED, [$version->id]);
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(DisplayList::class)
|
||||||
|
->assertSee('https://cabinet.b2in.eu/display/', false)
|
||||||
|
->assertSee('https://cabinet.b2in.eu/display/?id='.$display->id, false);
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,31 @@ beforeEach(function () {
|
||||||
Storage::fake('public');
|
Storage::fake('public');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a real, tiny mp4 on disk using ffmpeg and return the path.
|
||||||
|
* Returns null when ffmpeg is unavailable so callers can skip.
|
||||||
|
*/
|
||||||
|
function makeRealTestVideo(): ?string
|
||||||
|
{
|
||||||
|
$ffmpeg = trim((string) shell_exec('command -v ffmpeg'));
|
||||||
|
|
||||||
|
if ($ffmpeg === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = tempnam(sys_get_temp_dir(), 'vid_').'.mp4';
|
||||||
|
|
||||||
|
$command = sprintf(
|
||||||
|
'%s -y -f lavfi -i testsrc=duration=2:size=320x240:rate=10 -pix_fmt yuv420p %s 2>/dev/null',
|
||||||
|
escapeshellarg($ffmpeg),
|
||||||
|
escapeshellarg($path)
|
||||||
|
);
|
||||||
|
|
||||||
|
shell_exec($command);
|
||||||
|
|
||||||
|
return is_file($path) && filesize($path) > 0 ? $path : null;
|
||||||
|
}
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// MODEL TESTS
|
// MODEL TESTS
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
@ -66,6 +91,20 @@ it('returns display name from title or filename', function () {
|
||||||
expect($withoutTitle->getDisplayName())->toBe('file.mp4');
|
expect($withoutTitle->getDisplayName())->toBe('file.mp4');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('returns the stored poster as thumbnail url for a video', function () {
|
||||||
|
$media = DisplayMedia::factory()->video()->create([
|
||||||
|
'thumbnail_path' => 'display-media/2026/05/tour-poster.jpg',
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect($media->getThumbnailUrl())->toContain('display-media/2026/05/tour-poster.jpg');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has no thumbnail url for a video without a poster', function () {
|
||||||
|
$media = DisplayMedia::factory()->video()->create(['thumbnail_path' => null]);
|
||||||
|
|
||||||
|
expect($media->getThumbnailUrl())->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// SCOPE TESTS
|
// SCOPE TESTS
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
@ -105,6 +144,41 @@ it('filters by collection scope', function () {
|
||||||
expect(DisplayMedia::inCollection('immobilien')->count())->toBe(1);
|
expect(DisplayMedia::inCollection('immobilien')->count())->toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps preceding filters when combined with the search scope', function () {
|
||||||
|
DisplayMedia::factory()->create([
|
||||||
|
'type' => 'image',
|
||||||
|
'filename' => 'haus.webp',
|
||||||
|
'title' => 'Schaufenster',
|
||||||
|
]);
|
||||||
|
DisplayMedia::factory()->video()->create([
|
||||||
|
'filename' => 'tour.mp4',
|
||||||
|
'title' => 'Haus Tour',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$results = DisplayMedia::images()->search('haus')->get();
|
||||||
|
|
||||||
|
expect($results)->toHaveCount(1)
|
||||||
|
->and($results->first()->filename)->toBe('haus.webp');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('search scope respects the active filter on the media picker', function () {
|
||||||
|
DisplayMedia::factory()->create([
|
||||||
|
'filename' => 'aktiv.webp',
|
||||||
|
'title' => 'Goya',
|
||||||
|
'is_active' => true,
|
||||||
|
]);
|
||||||
|
DisplayMedia::factory()->create([
|
||||||
|
'filename' => 'inaktiv.webp',
|
||||||
|
'title' => 'Goya',
|
||||||
|
'is_active' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$results = DisplayMedia::active()->search('goya')->get();
|
||||||
|
|
||||||
|
expect($results)->toHaveCount(1)
|
||||||
|
->and($results->first()->filename)->toBe('aktiv.webp');
|
||||||
|
});
|
||||||
|
|
||||||
it('filters by active scope', function () {
|
it('filters by active scope', function () {
|
||||||
DisplayMedia::factory()->create(['is_active' => true]);
|
DisplayMedia::factory()->create(['is_active' => true]);
|
||||||
DisplayMedia::factory()->create(['is_active' => false]);
|
DisplayMedia::factory()->create(['is_active' => false]);
|
||||||
|
|
@ -142,6 +216,33 @@ it('stores a video upload', function () {
|
||||||
->and($media->mime_type)->toBe('video/mp4');
|
->and($media->mime_type)->toBe('video/mp4');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('generates a poster thumbnail when a real video is uploaded', function () {
|
||||||
|
$videoPath = makeRealTestVideo();
|
||||||
|
|
||||||
|
if ($videoPath === null) {
|
||||||
|
$this->markTestSkipped('ffmpeg is not available.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$service = app(DisplayMediaService::class);
|
||||||
|
$file = new UploadedFile($videoPath, 'showroom.mp4', 'video/mp4', null, true);
|
||||||
|
|
||||||
|
$media = $service->storeUpload($file);
|
||||||
|
|
||||||
|
expect($media->thumbnail_path)->not->toBeNull();
|
||||||
|
expect(Storage::disk('public')->exists($media->thumbnail_path))->toBeTrue();
|
||||||
|
|
||||||
|
@unlink($videoPath);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not set a thumbnail when the video cannot be decoded', function () {
|
||||||
|
$service = app(DisplayMediaService::class);
|
||||||
|
$file = UploadedFile::fake()->create('broken.mp4', 500, 'video/mp4');
|
||||||
|
|
||||||
|
$media = $service->storeUpload($file);
|
||||||
|
|
||||||
|
expect($media->thumbnail_path)->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it('stores an svg upload as image media', function () {
|
it('stores an svg upload as image media', function () {
|
||||||
$service = app(DisplayMediaService::class);
|
$service = app(DisplayMediaService::class);
|
||||||
$file = UploadedFile::fake()->createWithContent(
|
$file = UploadedFile::fake()->createWithContent(
|
||||||
|
|
@ -158,6 +259,44 @@ it('stores an svg upload as image media', function () {
|
||||||
expect(Storage::disk('public')->exists($media->path))->toBeTrue();
|
expect(Storage::disk('public')->exists($media->path))->toBeTrue();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('resets pagination to page one when a filter changes', function () {
|
||||||
|
Volt::test('admin.cms.display-media-library')
|
||||||
|
->call('gotoPage', 3)
|
||||||
|
->set('filterType', 'video')
|
||||||
|
->assertSet('paginators.page', 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders external image media with an inline thumbnail', function () {
|
||||||
|
$media = DisplayMedia::factory()->external()->create([
|
||||||
|
'type' => 'image',
|
||||||
|
'external_url' => 'https://example.com/foto.jpg',
|
||||||
|
]);
|
||||||
|
|
||||||
|
Volt::test('admin.cms.display-media-library')
|
||||||
|
->assertSeeHtml('src="https://example.com/foto.jpg"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a stored poster image for a video with a thumbnail', function () {
|
||||||
|
DisplayMedia::factory()->video()->create([
|
||||||
|
'path' => 'display-media/2026/05/tour.mp4',
|
||||||
|
'thumbnail_path' => 'display-media/2026/05/tour-poster.jpg',
|
||||||
|
]);
|
||||||
|
|
||||||
|
Volt::test('admin.cms.display-media-library')
|
||||||
|
->assertSeeHtml('tour-poster.jpg');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders an inline video frame for an uploaded video without a poster', function () {
|
||||||
|
DisplayMedia::factory()->video()->create([
|
||||||
|
'path' => 'display-media/2026/05/clip.mp4',
|
||||||
|
'thumbnail_path' => null,
|
||||||
|
'mime_type' => 'video/mp4',
|
||||||
|
]);
|
||||||
|
|
||||||
|
Volt::test('admin.cms.display-media-library')
|
||||||
|
->assertSeeHtml('clip.mp4#t=1');
|
||||||
|
});
|
||||||
|
|
||||||
it('accepts svg uploads in the display media library', function () {
|
it('accepts svg uploads in the display media library', function () {
|
||||||
$file = UploadedFile::fake()->createWithContent(
|
$file = UploadedFile::fake()->createWithContent(
|
||||||
'brand.svg',
|
'brand.svg',
|
||||||
|
|
@ -237,6 +376,42 @@ it('deletes external media record', function () {
|
||||||
expect(DisplayMedia::find($media->id))->toBeNull();
|
expect(DisplayMedia::find($media->id))->toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// COMMAND TESTS
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
it('reports when there are no videos to process', function () {
|
||||||
|
$this->artisan('display-media:generate-video-thumbnails')
|
||||||
|
->expectsOutputToContain('Keine Videos zum Verarbeiten gefunden.')
|
||||||
|
->assertSuccessful();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('backfills posters for existing uploaded videos', function () {
|
||||||
|
$videoPath = makeRealTestVideo();
|
||||||
|
|
||||||
|
if ($videoPath === null) {
|
||||||
|
$this->markTestSkipped('ffmpeg is not available.');
|
||||||
|
}
|
||||||
|
|
||||||
|
Storage::disk('public')->putFileAs(
|
||||||
|
'display-media/2026/05',
|
||||||
|
new UploadedFile($videoPath, 'clip.mp4', 'video/mp4', null, true),
|
||||||
|
'clip.mp4'
|
||||||
|
);
|
||||||
|
|
||||||
|
$media = DisplayMedia::factory()->video()->create([
|
||||||
|
'source_type' => 'upload',
|
||||||
|
'path' => 'display-media/2026/05/clip.mp4',
|
||||||
|
'thumbnail_path' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->artisan('display-media:generate-video-thumbnails')->assertSuccessful();
|
||||||
|
|
||||||
|
expect($media->fresh()->thumbnail_path)->not->toBeNull();
|
||||||
|
|
||||||
|
@unlink($videoPath);
|
||||||
|
});
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// ROUTE TESTS
|
// ROUTE TESTS
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ use App\Models\Display;
|
||||||
use App\Models\DisplayPlaylist;
|
use App\Models\DisplayPlaylist;
|
||||||
use App\Models\DisplayPlaylistItem;
|
use App\Models\DisplayPlaylistItem;
|
||||||
use App\Models\DisplayVersion;
|
use App\Models\DisplayVersion;
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
use Illuminate\Support\Facades\Schema;
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
it('creates the display_playlists table with unique status per display', function () {
|
it('creates the display_playlists table with unique status per display', function () {
|
||||||
|
|
@ -27,64 +26,8 @@ it('adds is_test and preview_token to displays', function () {
|
||||||
expect(Schema::hasColumns('displays', ['is_test', 'preview_token']))->toBeTrue();
|
expect(Schema::hasColumns('displays', ['is_test', 'preview_token']))->toBeTrue();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('migrates existing pivot entries into a published playlist with same ordering', function () {
|
it('drops the legacy display version pivot table', function () {
|
||||||
$display = Display::factory()->create();
|
expect(Schema::hasTable('display_display_version'))->toBeFalse();
|
||||||
$moduleA = DisplayVersion::factory()->create(['name' => 'Modul A']);
|
|
||||||
$moduleB = DisplayVersion::factory()->create(['name' => 'Modul B']);
|
|
||||||
|
|
||||||
DB::table('display_playlists')->where('display_id', $display->id)->delete();
|
|
||||||
|
|
||||||
DB::table('display_display_version')->insert([
|
|
||||||
['display_id' => $display->id, 'display_version_id' => $moduleA->id, 'sort_order' => 0],
|
|
||||||
['display_id' => $display->id, 'display_version_id' => $moduleB->id, 'sort_order' => 1],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$migration = require database_path('migrations/2026_05_11_113330_migrate_pivot_to_display_playlists.php');
|
|
||||||
$migration->up();
|
|
||||||
|
|
||||||
$playlist = DisplayPlaylist::query()
|
|
||||||
->where('display_id', $display->id)
|
|
||||||
->where('status', DisplayPlaylist::STATUS_PUBLISHED)
|
|
||||||
->first();
|
|
||||||
|
|
||||||
expect($playlist)->not->toBeNull();
|
|
||||||
expect($playlist->published_at)->not->toBeNull();
|
|
||||||
|
|
||||||
$orderedIds = $playlist->items()->pluck('display_version_id')->all();
|
|
||||||
expect($orderedIds)->toBe([$moduleA->id, $moduleB->id]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('is idempotent and does not duplicate published playlists on re-run', function () {
|
|
||||||
$display = Display::factory()->create();
|
|
||||||
$module = DisplayVersion::factory()->create();
|
|
||||||
|
|
||||||
DB::table('display_playlists')->where('display_id', $display->id)->delete();
|
|
||||||
|
|
||||||
DB::table('display_display_version')->insert([
|
|
||||||
['display_id' => $display->id, 'display_version_id' => $module->id, 'sort_order' => 0],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$migration = require database_path('migrations/2026_05_11_113330_migrate_pivot_to_display_playlists.php');
|
|
||||||
$migration->up();
|
|
||||||
$migration->up();
|
|
||||||
|
|
||||||
$count = DisplayPlaylist::query()
|
|
||||||
->where('display_id', $display->id)
|
|
||||||
->where('status', DisplayPlaylist::STATUS_PUBLISHED)
|
|
||||||
->count();
|
|
||||||
|
|
||||||
expect($count)->toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not break the legacy versions() relation', function () {
|
|
||||||
$display = Display::factory()->create();
|
|
||||||
$module = DisplayVersion::factory()->create();
|
|
||||||
|
|
||||||
DB::table('display_display_version')->insert([
|
|
||||||
['display_id' => $display->id, 'display_version_id' => $module->id, 'sort_order' => 0],
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect($display->fresh()->versions)->toHaveCount(1);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('exposes a live playlist relation and a draft playlist relation on display', function () {
|
it('exposes a live playlist relation and a draft playlist relation on display', function () {
|
||||||
|
|
|
||||||
|
|
@ -115,9 +115,45 @@ test('returns playlist with b2in config', function () {
|
||||||
$response->assertSuccessful();
|
$response->assertSuccessful();
|
||||||
$response->assertJsonPath('playlist.0.type', 'b2in');
|
$response->assertJsonPath('playlist.0.type', 'b2in');
|
||||||
$response->assertJsonPath('playlist.0.settings.theme', 'dark');
|
$response->assertJsonPath('playlist.0.settings.theme', 'dark');
|
||||||
|
$response->assertJsonPath('playlist.0.settings.show_footer', true);
|
||||||
|
$response->assertJsonPath('playlist.0.settings.logo_position', 'top-left');
|
||||||
|
$response->assertJsonPath('playlist.0.settings.claim_position', 'top-right');
|
||||||
$response->assertJsonPath('playlist.0.items.0.category', 'immobilien');
|
$response->assertJsonPath('playlist.0.items.0.category', 'immobilien');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('b2in config passes through custom brand positions and footer toggle', function () {
|
||||||
|
$version = DisplayVersion::factory()->create([
|
||||||
|
'type' => 'b2in',
|
||||||
|
'settings' => [
|
||||||
|
'show_footer' => false,
|
||||||
|
'logo_position' => 'bottom-right',
|
||||||
|
'claim_position' => 'top-left',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
DisplayVersionItem::factory()->create([
|
||||||
|
'display_version_id' => $version->id,
|
||||||
|
'item_type' => 'media',
|
||||||
|
'content' => [
|
||||||
|
'category' => 'sonstiges',
|
||||||
|
'media_type' => 'image',
|
||||||
|
'media_url' => '../assets/test.jpg',
|
||||||
|
'headline' => 'H',
|
||||||
|
'subline' => 'S',
|
||||||
|
'duration_seconds' => 10,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
$display = Display::factory()->create();
|
||||||
|
publishDisplayModules($display, [$version->id]);
|
||||||
|
|
||||||
|
$response = $this->getJson("/api/display/{$display->id}/config");
|
||||||
|
|
||||||
|
$response->assertSuccessful();
|
||||||
|
$response->assertJsonPath('playlist.0.settings.show_footer', false);
|
||||||
|
$response->assertJsonPath('playlist.0.settings.logo_position', 'bottom-right');
|
||||||
|
$response->assertJsonPath('playlist.0.settings.claim_position', 'top-left');
|
||||||
|
$response->assertJsonPath('playlist.0.items.0.category', 'sonstiges');
|
||||||
|
});
|
||||||
|
|
||||||
test('returns playlist with offers config', function () {
|
test('returns playlist with offers config', function () {
|
||||||
$version = DisplayVersion::factory()->create([
|
$version = DisplayVersion::factory()->create([
|
||||||
'type' => 'offers',
|
'type' => 'offers',
|
||||||
|
|
@ -214,14 +250,34 @@ test('check endpoint returns only updated_at', function () {
|
||||||
$response->assertJsonStructure(['updated_at']);
|
$response->assertJsonStructure(['updated_at']);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('display config ignores legacy pivot and reads published playlist', function () {
|
test('display overview lists active displays with published modules', function () {
|
||||||
$legacyVersion = DisplayVersion::factory()->create(['type' => 'video-display', 'name' => 'Legacy']);
|
$liveVersion = DisplayVersion::factory()->create(['name' => 'Live Module']);
|
||||||
DisplayVersionItem::factory()->create([
|
$activeDisplay = Display::factory()->create([
|
||||||
'display_version_id' => $legacyVersion->id,
|
'name' => 'Showroom Eingang',
|
||||||
'item_type' => 'video',
|
'location' => 'Bielefeld',
|
||||||
'content' => ['filename' => 'legacy.mp4', 'title' => 'Legacy', 'position' => 25],
|
'is_active' => true,
|
||||||
]);
|
]);
|
||||||
|
publishDisplayModules($activeDisplay, [$liveVersion->id]);
|
||||||
|
|
||||||
|
$inactiveDisplay = Display::factory()->create(['is_active' => false]);
|
||||||
|
publishDisplayModules($inactiveDisplay, [$liveVersion->id]);
|
||||||
|
|
||||||
|
Display::factory()->create(['name' => 'Ohne Live']);
|
||||||
|
|
||||||
|
$response = $this->getJson('/api/display/overview');
|
||||||
|
|
||||||
|
$response->assertSuccessful()
|
||||||
|
->assertJsonCount(1, 'displays')
|
||||||
|
->assertJsonPath('displays.0.id', $activeDisplay->id)
|
||||||
|
->assertJsonPath('displays.0.name', 'Showroom Eingang')
|
||||||
|
->assertJsonPath('displays.0.location', 'Bielefeld')
|
||||||
|
->assertJsonPath('displays.0.is_active', true)
|
||||||
|
->assertJsonPath('displays.0.is_live', true)
|
||||||
|
->assertJsonPath('displays.0.module_count', 1)
|
||||||
|
->assertJsonPath('displays.0.url', 'https://cabinet.b2in.eu/display/?id='.$activeDisplay->id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('display config reads published playlist', function () {
|
||||||
$publishedVersion = DisplayVersion::factory()->create(['type' => 'video-display', 'name' => 'Published']);
|
$publishedVersion = DisplayVersion::factory()->create(['type' => 'video-display', 'name' => 'Published']);
|
||||||
DisplayVersionItem::factory()->create([
|
DisplayVersionItem::factory()->create([
|
||||||
'display_version_id' => $publishedVersion->id,
|
'display_version_id' => $publishedVersion->id,
|
||||||
|
|
@ -230,7 +286,6 @@ test('display config ignores legacy pivot and reads published playlist', functio
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$display = Display::factory()->create();
|
$display = Display::factory()->create();
|
||||||
$display->versions()->attach($legacyVersion->id, ['sort_order' => 0]);
|
|
||||||
publishDisplayModules($display, [$publishedVersion->id]);
|
publishDisplayModules($display, [$publishedVersion->id]);
|
||||||
|
|
||||||
$response = $this->getJson("/api/display/{$display->id}/config");
|
$response = $this->getJson("/api/display/{$display->id}/config");
|
||||||
|
|
@ -301,38 +356,32 @@ test('module preview returns a single module config', function () {
|
||||||
$response->assertJsonPath('playlist.0.items.0.category', 'moebel');
|
$response->assertJsonPath('playlist.0.items.0.category', 'moebel');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('module preview exposes configurable player chrome settings', function () {
|
test('offers slides carry their own logo and brand chrome', function () {
|
||||||
$version = DisplayVersion::factory()->create([
|
$version = DisplayVersion::factory()->create([
|
||||||
'type' => 'offers',
|
'type' => 'offers',
|
||||||
'name' => 'Custom Chrome',
|
'name' => 'Custom Chrome',
|
||||||
'settings' => [
|
|
||||||
'logo_url' => '/storage/display-media/logo.svg',
|
|
||||||
'brand_text' => 'Musterstadt',
|
|
||||||
'footer_claim' => 'Beratung im Schauraum',
|
|
||||||
'footer_url' => 'cabinet-bielefeld.de',
|
|
||||||
'qr_default_title' => 'Mehr Infos',
|
|
||||||
'qr_subtitle' => 'Jetzt scannen',
|
|
||||||
],
|
|
||||||
]);
|
]);
|
||||||
DisplayVersionItem::factory()->create([
|
DisplayVersionItem::factory()->create([
|
||||||
'display_version_id' => $version->id,
|
'display_version_id' => $version->id,
|
||||||
'item_type' => 'slide',
|
'item_type' => 'slide',
|
||||||
'content' => [
|
'content' => [
|
||||||
'type' => 'intro',
|
'type' => 'detail',
|
||||||
'title' => 'Intro',
|
'title' => 'Intro',
|
||||||
'image_url' => '../assets/intro.jpg',
|
'image_url' => '../assets/intro.jpg',
|
||||||
|
'show_logo' => true,
|
||||||
|
'logo_url' => '/storage/display-media/logo.svg',
|
||||||
|
'brand_text' => 'Musterstadt',
|
||||||
|
'brand_tagline' => 'Beratung im Schauraum',
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$response = $this->getJson("/api/display/module/{$version->id}/preview");
|
$response = $this->getJson("/api/display/module/{$version->id}/preview");
|
||||||
|
|
||||||
$response->assertSuccessful();
|
$response->assertSuccessful();
|
||||||
$response->assertJsonPath('playlist.0.settings.logo_url', '/storage/display-media/logo.svg');
|
$response->assertJsonPath('playlist.0.slides.0.show_logo', true);
|
||||||
$response->assertJsonPath('playlist.0.settings.brand_text', 'Musterstadt');
|
$response->assertJsonPath('playlist.0.slides.0.logo_url', '/storage/display-media/logo.svg');
|
||||||
$response->assertJsonPath('playlist.0.settings.footer_claim', 'Beratung im Schauraum');
|
$response->assertJsonPath('playlist.0.slides.0.brand_text', 'Musterstadt');
|
||||||
$response->assertJsonPath('playlist.0.settings.footer_url', 'cabinet-bielefeld.de');
|
$response->assertJsonPath('playlist.0.slides.0.brand_tagline', 'Beratung im Schauraum');
|
||||||
$response->assertJsonPath('playlist.0.settings.qr_default_title', 'Mehr Infos');
|
|
||||||
$response->assertJsonPath('playlist.0.settings.qr_subtitle', 'Jetzt scannen');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('module item preview returns only the selected slide', function () {
|
test('module item preview returns only the selected slide', function () {
|
||||||
|
|
@ -391,12 +440,34 @@ test('display player keeps previews in a strict 9 by 16 viewport', function () {
|
||||||
->toContain('width: min(100vw, calc(100vh * 9 / 16));')
|
->toContain('width: min(100vw, calc(100vh * 9 / 16));')
|
||||||
->toContain('height: min(100vh, calc(100vw * 16 / 9));')
|
->toContain('height: min(100vh, calc(100vw * 16 / 9));')
|
||||||
->toContain('container-type: size;')
|
->toContain('container-type: size;')
|
||||||
|
->toContain("if (hostname === 'cabinet.b2in.eu') {")
|
||||||
|
->toContain("return 'https://portal.b2in.eu';")
|
||||||
|
->toContain('/api/display/overview')
|
||||||
|
->toContain('Aktive Live-Displays')
|
||||||
|
->toContain('renderOverview(data.displays || [])')
|
||||||
->toContain('translate(${offsetX}px, ${offsetY}px) scale(${scale})')
|
->toContain('translate(${offsetX}px, ${offsetY}px) scale(${scale})')
|
||||||
->toContain('this.settings.logo_url')
|
|
||||||
->toContain('this.settings.footer_claim')
|
->toContain('this.settings.footer_claim')
|
||||||
->toContain('this.settings.footer_url')
|
->toContain('this.settings.footer_url')
|
||||||
->toContain('this.settings.header_logo_url')
|
->toContain('this.settings.header_logo_url')
|
||||||
->toContain('this.settings.qr_label');
|
->toContain('this.settings.qr_label')
|
||||||
|
->toContain('this.settings.show_footer !== false')
|
||||||
|
->toContain('this.settings.show_logo !== false')
|
||||||
|
->toContain('this.settings.show_claim !== false')
|
||||||
|
->toContain('this.settings.logo_position')
|
||||||
|
->toContain('this.settings.claim_position')
|
||||||
|
->toContain('b2in-brand-logo pos-')
|
||||||
|
->toContain('b2in-scrim')
|
||||||
|
->toContain('.b2in-layer.no-footer .b2in-text');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('display player stops looping when the playlist has no playable content', function () {
|
||||||
|
$player = file_get_contents(public_path('_cabinet/display/index.html'));
|
||||||
|
|
||||||
|
expect($player)
|
||||||
|
->toContain('versionHasContent(version)')
|
||||||
|
->toContain('showEmptyPlaylist()')
|
||||||
|
->toContain('this.emptyVersionStreak >= this.playlist.length')
|
||||||
|
->toContain('Noch keine Inhalte vorhanden');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('preview player pages are reachable for valid display and module previews', function () {
|
test('preview player pages are reachable for valid display and module previews', function () {
|
||||||
|
|
@ -414,6 +485,20 @@ test('preview player pages are reachable for valid display and module previews',
|
||||||
->assertSuccessful();
|
->assertSuccessful();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('preview player is served with a revalidation cache header', function () {
|
||||||
|
$display = Display::factory()->create(['preview_token' => 'fresh-token']);
|
||||||
|
$version = DisplayVersion::factory()->create();
|
||||||
|
|
||||||
|
foreach ([
|
||||||
|
'/preview/fresh-token',
|
||||||
|
"/preview/module/{$version->id}",
|
||||||
|
] as $url) {
|
||||||
|
$response = $this->get($url);
|
||||||
|
$response->assertSuccessful();
|
||||||
|
expect($response->headers->get('Cache-Control'))->toContain('no-cache');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('existing display config api still works', function () {
|
test('existing display config api still works', function () {
|
||||||
$response = $this->getJson('/api/display/config');
|
$response = $this->getJson('/api/display/config');
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,10 @@
|
||||||
use App\Enums\DisplayVersionType;
|
use App\Enums\DisplayVersionType;
|
||||||
use App\Livewire\Admin\Cms\DisplayVersionEditor;
|
use App\Livewire\Admin\Cms\DisplayVersionEditor;
|
||||||
use App\Livewire\Admin\Cms\DisplayVersionList;
|
use App\Livewire\Admin\Cms\DisplayVersionList;
|
||||||
|
use App\Models\Display;
|
||||||
|
use App\Models\DisplayMedia;
|
||||||
|
use App\Models\DisplayPlaylist;
|
||||||
|
use App\Models\DisplayPlaylistItem;
|
||||||
use App\Models\DisplayVersion;
|
use App\Models\DisplayVersion;
|
||||||
use App\Models\DisplayVersionItem;
|
use App\Models\DisplayVersionItem;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
|
@ -36,6 +40,20 @@ test('display version list renders for authenticated users', function () {
|
||||||
$response->assertSeeLivewire(DisplayVersionList::class);
|
$response->assertSeeLivewire(DisplayVersionList::class);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('display dashboard documentation describes current workflow', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$this->get(route('admin.cms.display-dashboard'))
|
||||||
|
->assertSuccessful()
|
||||||
|
->assertSee('Live und Entwurf')
|
||||||
|
->assertSee('Meta-Einstellungen pflegen')
|
||||||
|
->assertSee('SVG-Logos')
|
||||||
|
->assertSee('/storage/...')
|
||||||
|
->assertSee('Entwurf in der 9:16-Vorschau');
|
||||||
|
});
|
||||||
|
|
||||||
test('can create a display version', function () {
|
test('can create a display version', function () {
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
|
@ -77,6 +95,29 @@ test('can delete a display version', function () {
|
||||||
expect(DisplayVersion::find($version->id))->toBeNull();
|
expect(DisplayVersion::find($version->id))->toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('cannot delete a display version that is used by a playlist', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$version = DisplayVersion::factory()->create(['name' => 'Genutztes Modul']);
|
||||||
|
$display = Display::factory()->create();
|
||||||
|
$playlist = DisplayPlaylist::factory()->create([
|
||||||
|
'display_id' => $display->id,
|
||||||
|
'status' => DisplayPlaylist::STATUS_PUBLISHED,
|
||||||
|
'published_at' => now(),
|
||||||
|
]);
|
||||||
|
DisplayPlaylistItem::factory()->create([
|
||||||
|
'display_playlist_id' => $playlist->id,
|
||||||
|
'display_version_id' => $version->id,
|
||||||
|
'sort_order' => 0,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(DisplayVersionList::class)
|
||||||
|
->call('deleteVersion', $version->id)
|
||||||
|
->assertSee('kann nicht gelöscht werden');
|
||||||
|
|
||||||
|
expect(DisplayVersion::find($version->id))->not->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
test('can toggle display version active status', function () {
|
test('can toggle display version active status', function () {
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
$version = DisplayVersion::factory()->create(['is_active' => true]);
|
$version = DisplayVersion::factory()->create(['is_active' => true]);
|
||||||
|
|
@ -104,19 +145,6 @@ test('display version editor renders with correct version data', function () {
|
||||||
$response->assertSeeLivewire(DisplayVersionEditor::class);
|
$response->assertSeeLivewire(DisplayVersionEditor::class);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('old display version routes redirect to module routes', function () {
|
|
||||||
$user = User::factory()->create();
|
|
||||||
$version = DisplayVersion::factory()->create();
|
|
||||||
|
|
||||||
$this->actingAs($user);
|
|
||||||
|
|
||||||
$this->get(route('admin.cms.display-versions'))
|
|
||||||
->assertRedirect(route('admin.cms.display-modules'));
|
|
||||||
|
|
||||||
$this->get(route('admin.cms.display-version-edit', $version))
|
|
||||||
->assertRedirect(route('admin.cms.display-module-edit', $version));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('display module editor renders module preview', function () {
|
test('display module editor renders module preview', function () {
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
$version = DisplayVersion::factory()->create(['name' => 'Preview Modul']);
|
$version = DisplayVersion::factory()->create(['name' => 'Preview Modul']);
|
||||||
|
|
@ -151,6 +179,81 @@ test('item edit modal renders module iframe preview', function () {
|
||||||
->assertSee('Schließen');
|
->assertSee('Schließen');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('item modal url inputs avoid wire:model.blur to prevent the teleported-modal cleanup crash', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$version = DisplayVersion::factory()->create(['type' => 'b2in']);
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(DisplayVersionEditor::class, ['displayVersion' => $version])
|
||||||
|
->call('openItemModal', null, 'media')
|
||||||
|
->assertDontSee('wire:model.blur', false)
|
||||||
|
->assertSee('wire:model.live.debounce.500ms="mediaUrl"', false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('video item modal labels the title field as an internal name not shown on screen', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$version = DisplayVersion::factory()->create(['type' => 'video-display']);
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(DisplayVersionEditor::class, ['displayVersion' => $version])
|
||||||
|
->call('openItemModal', null, 'video')
|
||||||
|
->assertSee('Name')
|
||||||
|
->assertSee('wird nicht im Video eingeblendet')
|
||||||
|
->assertDontSee('Titel (optional)');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('video playlist renders a media thumbnail for stored videos', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$version = DisplayVersion::factory()->create(['type' => 'video-display']);
|
||||||
|
DisplayVersionItem::factory()->create([
|
||||||
|
'display_version_id' => $version->id,
|
||||||
|
'item_type' => 'video',
|
||||||
|
'content' => ['filename' => '/storage/display-media/clip.mp4', 'title' => 'Clip', 'position' => 25],
|
||||||
|
]);
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(DisplayVersionEditor::class, ['displayVersion' => $version])
|
||||||
|
->assertSee('/storage/display-media/clip.mp4#t=1', false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('new item modal shows a placeholder instead of the module preview', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$version = DisplayVersion::factory()->create(['type' => 'video-display']);
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(DisplayVersionEditor::class, ['displayVersion' => $version])
|
||||||
|
->call('openItemModal', null, 'video')
|
||||||
|
->assertSee('Die Vorschau erscheint, sobald der Inhalt gespeichert wurde.')
|
||||||
|
->assertSee('src="about:blank"', false)
|
||||||
|
->assertDontSee('/preview/module/'.$version->id.'/item/', false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('item modal preview iframe keeps a stable wire:key to avoid morph crashes', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$version = DisplayVersion::factory()->create(['type' => 'video-display']);
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(DisplayVersionEditor::class, ['displayVersion' => $version])
|
||||||
|
->call('openItemModal', null, 'video')
|
||||||
|
->assertSee('wire:key="item-modal-preview"', false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('saved item modal shows the single-item preview iframe', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$version = DisplayVersion::factory()->create(['type' => 'video-display']);
|
||||||
|
$item = DisplayVersionItem::factory()->create([
|
||||||
|
'display_version_id' => $version->id,
|
||||||
|
'item_type' => 'video',
|
||||||
|
'content' => ['filename' => 'clip.mp4', 'title' => 'Clip', 'position' => 25],
|
||||||
|
]);
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(DisplayVersionEditor::class, ['displayVersion' => $version])
|
||||||
|
->call('openItemModal', $item->id)
|
||||||
|
->assertSee('wire:key="item-modal-preview"', false)
|
||||||
|
->assertSee('/preview/module/'.$version->id.'/item/'.$item->id, false);
|
||||||
|
});
|
||||||
|
|
||||||
test('can add a video item to video-display version', function () {
|
test('can add a video item to video-display version', function () {
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
$version = DisplayVersion::factory()->create(['type' => 'video-display']);
|
$version = DisplayVersion::factory()->create(['type' => 'video-display']);
|
||||||
|
|
@ -212,6 +315,26 @@ test('can edit an existing item', function () {
|
||||||
expect($item->content['title'])->toBe('New Title');
|
expect($item->content['title'])->toBe('New Title');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('editing an inactive item keeps it inactive', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$version = DisplayVersion::factory()->create(['type' => 'video-display']);
|
||||||
|
$item = DisplayVersionItem::factory()->create([
|
||||||
|
'display_version_id' => $version->id,
|
||||||
|
'item_type' => 'video',
|
||||||
|
'content' => ['filename' => 'old.mp4', 'title' => 'Old', 'position' => 25],
|
||||||
|
'is_active' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(DisplayVersionEditor::class, ['displayVersion' => $version])
|
||||||
|
->call('openItemModal', $item->id)
|
||||||
|
->assertSet('videoIsActive', false)
|
||||||
|
->set('videoTitle', 'New Title')
|
||||||
|
->call('saveItem');
|
||||||
|
|
||||||
|
expect($item->fresh()->is_active)->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
test('updating an item keeps modal open and refreshes iframe preview', function () {
|
test('updating an item keeps modal open and refreshes iframe preview', function () {
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
$version = DisplayVersion::factory()->create(['type' => 'offers']);
|
$version = DisplayVersion::factory()->create(['type' => 'offers']);
|
||||||
|
|
@ -298,12 +421,13 @@ test('can add a slide item to offers version', function () {
|
||||||
Livewire::actingAs($user)
|
Livewire::actingAs($user)
|
||||||
->test(DisplayVersionEditor::class, ['displayVersion' => $version])
|
->test(DisplayVersionEditor::class, ['displayVersion' => $version])
|
||||||
->call('openItemModal', null, 'slide')
|
->call('openItemModal', null, 'slide')
|
||||||
->set('slideType', 'product-hero')
|
|
||||||
->set('slideDuration', 10000)
|
->set('slideDuration', 10000)
|
||||||
->set('slideImageUrl', '../assets/goya1.jpg')
|
->set('slideImageUrl', '../assets/goya1.jpg')
|
||||||
|
->set('slideShowBadge', true)
|
||||||
->set('slideBadge', 'Einzelstück')
|
->set('slideBadge', 'Einzelstück')
|
||||||
->set('slideEyebrow', 'Hersteller: Sudbrock')
|
->set('slideEyebrow', 'Hersteller: Sudbrock')
|
||||||
->set('slideTitle', 'GOYA Sideboard')
|
->set('slideTitle', 'GOYA Sideboard')
|
||||||
|
->set('slideShowPrice', true)
|
||||||
->set('slidePrice', '489 €')
|
->set('slidePrice', '489 €')
|
||||||
->set('slideOriginalPrice', 'statt 4.744 €')
|
->set('slideOriginalPrice', 'statt 4.744 €')
|
||||||
->set('slideQrUrl', 'https://cabinet-bielefeld.de')
|
->set('slideQrUrl', 'https://cabinet-bielefeld.de')
|
||||||
|
|
@ -314,9 +438,10 @@ test('can add a slide item to offers version', function () {
|
||||||
$item = DisplayVersionItem::where('display_version_id', $version->id)->first();
|
$item = DisplayVersionItem::where('display_version_id', $version->id)->first();
|
||||||
expect($item)->not->toBeNull();
|
expect($item)->not->toBeNull();
|
||||||
expect($item->item_type)->toBe('slide');
|
expect($item->item_type)->toBe('slide');
|
||||||
expect($item->content['type'])->toBe('product-hero');
|
expect($item->content['type'])->toBe('detail');
|
||||||
expect($item->content['title'])->toBe('GOYA Sideboard');
|
expect($item->content['title'])->toBe('GOYA Sideboard');
|
||||||
expect($item->content['price'])->toBe('489 €');
|
expect($item->content['price'])->toBe('489 €');
|
||||||
|
expect($item->content['show_price'])->toBeTrue();
|
||||||
expect($item->content['image_url'])->toBe('../assets/goya1.jpg');
|
expect($item->content['image_url'])->toBe('../assets/goya1.jpg');
|
||||||
expect($item->content['qr_url'])->toBe('https://cabinet-bielefeld.de');
|
expect($item->content['qr_url'])->toBe('https://cabinet-bielefeld.de');
|
||||||
});
|
});
|
||||||
|
|
@ -328,14 +453,99 @@ test('can add a slide with bullets to offers version', function () {
|
||||||
Livewire::actingAs($user)
|
Livewire::actingAs($user)
|
||||||
->test(DisplayVersionEditor::class, ['displayVersion' => $version])
|
->test(DisplayVersionEditor::class, ['displayVersion' => $version])
|
||||||
->call('openItemModal', null, 'slide')
|
->call('openItemModal', null, 'slide')
|
||||||
->set('slideType', 'product-details')
|
|
||||||
->set('slideTitle', 'GOYA Sideboard')
|
->set('slideTitle', 'GOYA Sideboard')
|
||||||
|
->set('slideShowBullets', true)
|
||||||
->set('slideBullets', ['Einzelstück', 'Abholung möglich', 'Lieferung optional'])
|
->set('slideBullets', ['Einzelstück', 'Abholung möglich', 'Lieferung optional'])
|
||||||
->call('saveItem');
|
->call('saveItem');
|
||||||
|
|
||||||
$item = DisplayVersionItem::where('display_version_id', $version->id)->first();
|
$item = DisplayVersionItem::where('display_version_id', $version->id)->first();
|
||||||
expect($item->content['bullets'])->toHaveCount(3);
|
expect($item->content['bullets'])->toHaveCount(3);
|
||||||
expect($item->content['bullets'][0])->toBe('Einzelstück');
|
expect($item->content['bullets'][0])->toBe('Einzelstück');
|
||||||
|
expect($item->content['show_bullets'])->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('slide editor persists the strike-through original price option', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$version = DisplayVersion::factory()->create(['type' => 'offers']);
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(DisplayVersionEditor::class, ['displayVersion' => $version])
|
||||||
|
->call('openItemModal', null, 'slide')
|
||||||
|
->set('slideTitle', 'Streich Slide')
|
||||||
|
->set('slideShowPrice', true)
|
||||||
|
->set('slidePrice', '199 €')
|
||||||
|
->set('slideOriginalPrice', 'statt 399 €')
|
||||||
|
->set('slideStrikeOriginalPrice', true)
|
||||||
|
->call('saveItem');
|
||||||
|
|
||||||
|
$item = DisplayVersionItem::where('display_version_id', $version->id)->first();
|
||||||
|
expect($item->content['strike_original_price'])->toBeTrue();
|
||||||
|
expect($item->content['original_price'])->toBe('statt 399 €');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('slide editor no longer exposes a slide type selector', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$version = DisplayVersion::factory()->create(['type' => 'offers']);
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(DisplayVersionEditor::class, ['displayVersion' => $version])
|
||||||
|
->call('openItemModal', null, 'slide')
|
||||||
|
->assertDontSee('Slide-Typ')
|
||||||
|
->assertSee('Bild & Badge')
|
||||||
|
->assertSee('Badge anzeigen')
|
||||||
|
->assertSee('Aufzählung anzeigen')
|
||||||
|
->assertSee('QR-Code anzeigen');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hidden slide blocks are persisted via their show flags', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$version = DisplayVersion::factory()->create(['type' => 'offers']);
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(DisplayVersionEditor::class, ['displayVersion' => $version])
|
||||||
|
->call('openItemModal', null, 'slide')
|
||||||
|
->set('slideTitle', 'Nur Titel')
|
||||||
|
->set('slideShowBadge', false)
|
||||||
|
->set('slideShowEyebrow', false)
|
||||||
|
->set('slideShowBullets', false)
|
||||||
|
->set('slideShowPrice', false)
|
||||||
|
->set('slideShowQr', false)
|
||||||
|
->set('slideShowContact', false)
|
||||||
|
->call('saveItem');
|
||||||
|
|
||||||
|
$item = DisplayVersionItem::where('display_version_id', $version->id)->first();
|
||||||
|
expect($item->content['show_badge'])->toBeFalse();
|
||||||
|
expect($item->content['show_eyebrow'])->toBeFalse();
|
||||||
|
expect($item->content['show_bullets'])->toBeFalse();
|
||||||
|
expect($item->content['show_price'])->toBeFalse();
|
||||||
|
expect($item->content['show_qr'])->toBeFalse();
|
||||||
|
expect($item->content['show_contact'])->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('legacy slides without show flags keep rendering their populated blocks', function () {
|
||||||
|
$version = DisplayVersion::factory()->create(['type' => 'offers']);
|
||||||
|
DisplayVersionItem::factory()->create([
|
||||||
|
'display_version_id' => $version->id,
|
||||||
|
'item_type' => 'slide',
|
||||||
|
'content' => [
|
||||||
|
'type' => 'product-hero',
|
||||||
|
'title' => 'Legacy Slide',
|
||||||
|
'badge_text' => 'Einzelstück',
|
||||||
|
'price' => '489 €',
|
||||||
|
'bullets' => ['Punkt A'],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$config = app(\App\Services\DisplayPlaylistConfigBuilder::class)
|
||||||
|
->fromModules(DisplayVersion::whereKey($version->id)->with('items')->get());
|
||||||
|
|
||||||
|
$slide = $config['playlist'][0]['slides'][0];
|
||||||
|
|
||||||
|
expect($slide['show_badge'])->toBeTrue();
|
||||||
|
expect($slide['show_price'])->toBeTrue();
|
||||||
|
expect($slide['show_bullets'])->toBeTrue();
|
||||||
|
expect($slide['show_subline'])->toBeFalse();
|
||||||
|
expect($slide['show_disclaimer'])->toBeFalse();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('can edit a slide item with new fields', function () {
|
test('can edit a slide item with new fields', function () {
|
||||||
|
|
@ -398,22 +608,174 @@ test('can save version settings', function () {
|
||||||
expect($version->fresh()->settings['footer_prefix'])->toBe('powered by');
|
expect($version->fresh()->settings['footer_prefix'])->toBe('powered by');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('module settings expose player chrome fields in the editor', function () {
|
test('b2in defaults include brand position and footer toggle', function () {
|
||||||
|
$defaults = \App\Support\DisplayModuleSettings::defaults(DisplayVersionType::B2in);
|
||||||
|
|
||||||
|
expect($defaults)->toHaveKey('logo_position', 'top-left')
|
||||||
|
->and($defaults)->toHaveKey('claim_position', 'top-right')
|
||||||
|
->and($defaults)->toHaveKey('show_footer', true)
|
||||||
|
->and($defaults)->toHaveKey('show_logo', true)
|
||||||
|
->and($defaults)->toHaveKey('show_claim', true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('selecting a video medium auto-sets the media type to video', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$version = DisplayVersion::factory()->create(['type' => 'b2in']);
|
||||||
|
$media = DisplayMedia::factory()->video()->create();
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(DisplayVersionEditor::class, ['displayVersion' => $version])
|
||||||
|
->call('openItemModal', null, 'media')
|
||||||
|
->set('mediaType', 'image')
|
||||||
|
->call('onDisplayMediaSelected', 'mediaUrl', $media->id, $media->getUrl())
|
||||||
|
->assertSet('mediaType', 'video');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('selecting an image medium auto-sets the media type to image', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$version = DisplayVersion::factory()->create(['type' => 'b2in']);
|
||||||
|
$media = DisplayMedia::factory()->create(['type' => 'image']);
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(DisplayVersionEditor::class, ['displayVersion' => $version])
|
||||||
|
->call('openItemModal', null, 'media')
|
||||||
|
->set('mediaType', 'video')
|
||||||
|
->call('onDisplayMediaSelected', 'mediaUrl', $media->id, $media->getUrl())
|
||||||
|
->assertSet('mediaType', 'image');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('media item modal offers the sonstiges category and an automatic type hint', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$version = DisplayVersion::factory()->create(['type' => 'b2in']);
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(DisplayVersionEditor::class, ['displayVersion' => $version])
|
||||||
|
->call('openItemModal', null, 'media')
|
||||||
|
->assertSee('Sonstiges')
|
||||||
|
->assertSee('wird automatisch aus dem gewählten Medium erkannt');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('b2in settings editor shows the brand section and footer toggle', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$version = DisplayVersion::factory()->create(['type' => 'b2in']);
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(DisplayVersionEditor::class, ['displayVersion' => $version])
|
||||||
|
->assertSee('Marke')
|
||||||
|
->assertSee('Logo-Position')
|
||||||
|
->assertSee('Claim-Position')
|
||||||
|
->assertSee('Logo anzeigen')
|
||||||
|
->assertSee('Claim anzeigen')
|
||||||
|
->assertSee('Footer anzeigen');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('saving b2in settings persists logo and claim visibility', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$version = DisplayVersion::factory()->create(['type' => 'b2in']);
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(DisplayVersionEditor::class, ['displayVersion' => $version])
|
||||||
|
->call('openSettingsModal')
|
||||||
|
->set('settings.show_logo', false)
|
||||||
|
->set('settings.show_claim', false)
|
||||||
|
->call('saveSettings');
|
||||||
|
|
||||||
|
$settings = $version->fresh()->settings;
|
||||||
|
expect($settings['show_logo'])->toBeFalse()
|
||||||
|
->and($settings['show_claim'])->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('enabling the footer pulls bottom brand positions back to the top', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$version = DisplayVersion::factory()->create(['type' => 'b2in']);
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(DisplayVersionEditor::class, ['displayVersion' => $version])
|
||||||
|
->set('settings.show_footer', false)
|
||||||
|
->set('settings.logo_position', 'bottom-left')
|
||||||
|
->set('settings.claim_position', 'bottom-right')
|
||||||
|
->set('settings.show_footer', true)
|
||||||
|
->assertSet('settings.logo_position', 'top-left')
|
||||||
|
->assertSet('settings.claim_position', 'top-right');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bottom brand positions are kept when the footer is hidden', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$version = DisplayVersion::factory()->create(['type' => 'b2in']);
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(DisplayVersionEditor::class, ['displayVersion' => $version])
|
||||||
|
->set('settings.show_footer', false)
|
||||||
|
->set('settings.logo_position', 'bottom-left')
|
||||||
|
->assertSet('settings.logo_position', 'bottom-left');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('claim never shares the same corner as the logo', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$version = DisplayVersion::factory()->create(['type' => 'b2in']);
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(DisplayVersionEditor::class, ['displayVersion' => $version])
|
||||||
|
->set('settings.show_footer', false)
|
||||||
|
->set('settings.logo_position', 'bottom-left')
|
||||||
|
->set('settings.claim_position', 'bottom-left')
|
||||||
|
->assertSet('settings.claim_position', fn ($value) => $value !== 'bottom-left');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('saving b2in settings persists brand positions and footer toggle', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$version = DisplayVersion::factory()->create(['type' => 'b2in']);
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(DisplayVersionEditor::class, ['displayVersion' => $version])
|
||||||
|
->call('openSettingsModal')
|
||||||
|
->set('settings.show_footer', false)
|
||||||
|
->set('settings.logo_position', 'bottom-right')
|
||||||
|
->set('settings.claim_position', 'top-left')
|
||||||
|
->call('saveSettings');
|
||||||
|
|
||||||
|
$settings = $version->fresh()->settings;
|
||||||
|
expect($settings['show_footer'])->toBeFalse()
|
||||||
|
->and($settings['logo_position'])->toBe('bottom-right')
|
||||||
|
->and($settings['claim_position'])->toBe('top-left');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('offers meta settings only keep loop and transition', function () {
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
$version = DisplayVersion::factory()->create(['type' => 'offers']);
|
$version = DisplayVersion::factory()->create(['type' => 'offers']);
|
||||||
|
|
||||||
Livewire::actingAs($user)
|
Livewire::actingAs($user)
|
||||||
->test(DisplayVersionEditor::class, ['displayVersion' => $version])
|
->test(DisplayVersionEditor::class, ['displayVersion' => $version])
|
||||||
->assertSee('Meta-Einstellungen für dieses Modul')
|
->assertSee('Meta-Einstellungen für dieses Modul')
|
||||||
->assertSee('Diese Werte gelten für die gesamte Media-Playlist bzw. alle Slides dieses Moduls.')
|
|
||||||
->call('openSettingsModal')
|
->call('openSettingsModal')
|
||||||
->assertSee('Branding')
|
->assertSee('Endlosschleife')
|
||||||
->assertSee('Logo URL')
|
->assertSee('Transition')
|
||||||
->assertDontSee('Logo Alt-Text')
|
->assertSee('Transition-Dauer (ms)')
|
||||||
->assertSee('Brand-Text')
|
->assertDontSee('Branding')
|
||||||
->assertSee('Footer & QR für alle Slides')
|
->assertDontSee('Brand-Text')
|
||||||
->assertSee('Footer-Claim')
|
->assertDontSee('Footer & QR für alle Slides')
|
||||||
->assertSee('Web/QR-URL')
|
->assertDontSee('Footer-Claim');
|
||||||
->assertSee('Standard QR-Titel')
|
});
|
||||||
->assertSee('QR-Unterzeile');
|
|
||||||
|
test('offer slide editor manages logo and brand text per slide', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$version = DisplayVersion::factory()->create(['type' => 'offers']);
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(DisplayVersionEditor::class, ['displayVersion' => $version])
|
||||||
|
->call('openItemModal', null, 'slide')
|
||||||
|
->assertSee('Logo & Marke')
|
||||||
|
->assertSee('Logo & Marken-Text anzeigen')
|
||||||
|
->set('slideTitle', 'Logo Slide')
|
||||||
|
->set('slideShowLogo', true)
|
||||||
|
->set('slideLogoUrl', '/storage/display-media/logo.svg')
|
||||||
|
->set('slideBrandText', 'Musterstadt')
|
||||||
|
->set('slideBrandTagline', 'Beratung • Lieferung')
|
||||||
|
->call('saveItem');
|
||||||
|
|
||||||
|
$item = DisplayVersionItem::where('display_version_id', $version->id)->first();
|
||||||
|
expect($item->content['show_logo'])->toBeTrue();
|
||||||
|
expect($item->content['logo_url'])->toBe('/storage/display-media/logo.svg');
|
||||||
|
expect($item->content['brand_text'])->toBe('Musterstadt');
|
||||||
|
expect($item->content['brand_tagline'])->toBe('Beratung • Lieferung');
|
||||||
});
|
});
|
||||||
|
|
|
||||||
41
tests/Feature/LayoutScriptOrderTest.php
Normal file
41
tests/Feature/LayoutScriptOrderTest.php
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Support\Facades\URL;
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
URL::forceRootUrl('https://'.config('domains.domain_portal'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('admin layout loads livewire before flux so flux can register its alpine components', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$content = $this->actingAs($user)
|
||||||
|
->get(route('admin.cms.display-modules'))
|
||||||
|
->assertSuccessful()
|
||||||
|
->getContent();
|
||||||
|
|
||||||
|
$livewirePosition = strpos($content, 'livewire.js?id=');
|
||||||
|
$fluxPosition = strpos($content, '/flux/flux');
|
||||||
|
|
||||||
|
expect($livewirePosition)->not->toBeFalse('Livewire script tag is missing from the layout.');
|
||||||
|
expect($fluxPosition)->not->toBeFalse('Flux script tag is missing from the layout.');
|
||||||
|
expect($livewirePosition)->toBeLessThan(
|
||||||
|
$fluxPosition,
|
||||||
|
'Livewire must be loaded before Flux; otherwise window.Alpine is undefined when flux.js registers fluxModal/fluxSelectSearchClearable.'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('admin layout loads the livewire script only once', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$content = $this->actingAs($user)
|
||||||
|
->get(route('admin.cms.display-modules'))
|
||||||
|
->assertSuccessful()
|
||||||
|
->getContent();
|
||||||
|
|
||||||
|
expect(substr_count($content, 'livewire.js?id='))->toBe(
|
||||||
|
1,
|
||||||
|
'Livewire must only be injected once; a duplicate script tag creates multiple Alpine instances.'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
@ -57,11 +57,11 @@ it('announcement bar visible on about', fn () => $this->get('/about')->assertSee
|
||||||
it('homepage has navigation', fn () => $this->get('/')->assertSee('Immobilien')->assertSee('Netzwerk')->assertSee('Magazin'));
|
it('homepage has navigation', fn () => $this->get('/')->assertSee('Immobilien')->assertSee('Netzwerk')->assertSee('Magazin'));
|
||||||
it('immobilien has all five sections', function () {
|
it('immobilien has all five sections', function () {
|
||||||
$this->get('/immobilien')
|
$this->get('/immobilien')
|
||||||
->assertSee('Dynamik')
|
->assertSee('Warum Dubai für Investoren relevant bleibt')
|
||||||
->assertSee('Investment in Dubai')
|
->assertSee('Der Kaufprozess mit B2in')
|
||||||
->assertSee('Kaufprozess')
|
->assertSee('Passt Dubai zu Ihrer Investmentstrategie?')
|
||||||
->assertSee('Meine Aufgabe')
|
->assertSee('Einrichtung mitdenken')
|
||||||
->assertSee('Investor');
|
->assertSee('Investoren');
|
||||||
});
|
});
|
||||||
it('netzwerk has teaser cards', fn () => $this->get('/netzwerk')->assertSee('Einrichtungsnetzwerk')->assertSee('In Entwicklung'));
|
it('netzwerk has teaser cards', fn () => $this->get('/netzwerk')->assertSee('Einrichtungsnetzwerk')->assertSee('In Entwicklung'));
|
||||||
it('magazin has all five articles', function () {
|
it('magazin has all five articles', function () {
|
||||||
|
|
|
||||||
19
tests/Feature/TestDisplaySeederTest.php
Normal file
19
tests/Feature/TestDisplaySeederTest.php
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Models\Display;
|
||||||
|
use Database\Seeders\TestDisplaySeeder;
|
||||||
|
|
||||||
|
it('seeds exactly one test display', function () {
|
||||||
|
$this->seed(TestDisplaySeeder::class);
|
||||||
|
|
||||||
|
expect(Display::query()->where('is_test', true)->count())->toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is idempotent and does not create a second test display', function () {
|
||||||
|
$this->seed(TestDisplaySeeder::class);
|
||||||
|
$this->seed(TestDisplaySeeder::class);
|
||||||
|
|
||||||
|
expect(Display::query()->where('is_test', true)->count())->toBe(1);
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue