12-05-2026 admin, Panel Displays
This commit is contained in:
parent
0762e3beac
commit
6a65354f4c
43 changed files with 3273 additions and 410 deletions
38
app/Http/Controllers/Api/DisplayPreviewController.php
Normal file
38
app/Http/Controllers/Api/DisplayPreviewController.php
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Display;
|
||||
use App\Services\DisplayPlaylistConfigBuilder;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
|
||||
class DisplayPreviewController extends Controller
|
||||
{
|
||||
public function show(string $token): BinaryFileResponse
|
||||
{
|
||||
Display::query()
|
||||
->where('preview_token', $token)
|
||||
->firstOrFail();
|
||||
|
||||
return response()->file(public_path('_cabinet/display/index.html'));
|
||||
}
|
||||
|
||||
public function config(string $token, DisplayPlaylistConfigBuilder $configBuilder): JsonResponse
|
||||
{
|
||||
$display = Display::query()
|
||||
->where('preview_token', $token)
|
||||
->firstOrFail();
|
||||
|
||||
$playlist = $display->draftPlaylist()
|
||||
->with('modules.items')
|
||||
->first();
|
||||
|
||||
if (! $playlist || $playlist->modules->isEmpty()) {
|
||||
return response()->json(['error' => 'Display preview not configured'], 404);
|
||||
}
|
||||
|
||||
return response()->json($configBuilder->fromPlaylist($playlist));
|
||||
}
|
||||
}
|
||||
|
|
@ -4,148 +4,46 @@ namespace App\Http\Controllers\Api;
|
|||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Display;
|
||||
use App\Services\DisplayPlaylistConfigBuilder;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class DisplayVersionApiController extends Controller
|
||||
{
|
||||
public function config(Display $display): JsonResponse
|
||||
public function config(Display $display, DisplayPlaylistConfigBuilder $configBuilder): JsonResponse
|
||||
{
|
||||
if (! $display->is_active) {
|
||||
return response()->json(['error' => 'Display not configured'], 404);
|
||||
}
|
||||
|
||||
$display->load('versions');
|
||||
$playlist = $display->livePlaylist()
|
||||
->with('modules.items')
|
||||
->first();
|
||||
|
||||
if ($display->versions->isEmpty()) {
|
||||
if (! $playlist || $playlist->modules->isEmpty()) {
|
||||
return response()->json(['error' => 'Display not configured'], 404);
|
||||
}
|
||||
|
||||
$playlist = [];
|
||||
|
||||
foreach ($display->versions as $version) {
|
||||
$items = $version->activeItems()->get();
|
||||
|
||||
$entry = match ($version->type->value) {
|
||||
'video-display' => $this->videoDisplayData($version, $items),
|
||||
'b2in' => $this->b2inData($version, $items),
|
||||
'offers' => $this->offersData($version, $items),
|
||||
default => null,
|
||||
};
|
||||
|
||||
if ($entry) {
|
||||
$playlist[] = $entry;
|
||||
}
|
||||
return response()->json($configBuilder->fromPlaylist($playlist));
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'playlist' => $playlist,
|
||||
'updated_at' => $display->versions->max('updated_at')?->toIso8601String(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function check(Display $display): JsonResponse
|
||||
public function check(Display $display, DisplayPlaylistConfigBuilder $configBuilder): JsonResponse
|
||||
{
|
||||
if (! $display->is_active) {
|
||||
return response()->json(['error' => 'Display not configured'], 404);
|
||||
}
|
||||
|
||||
$display->load('versions');
|
||||
$playlist = $display->livePlaylist()
|
||||
->with('modules.items')
|
||||
->first();
|
||||
|
||||
if ($display->versions->isEmpty()) {
|
||||
if (! $playlist || $playlist->modules->isEmpty()) {
|
||||
return response()->json(['error' => 'Display not configured'], 404);
|
||||
}
|
||||
|
||||
$config = $configBuilder->fromPlaylist($playlist);
|
||||
|
||||
return response()->json([
|
||||
'updated_at' => $display->versions->max('updated_at')?->toIso8601String(),
|
||||
'updated_at' => $config['updated_at'],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function videoDisplayData($version, $items): array
|
||||
{
|
||||
$videos = $items->where('item_type', 'video')->values()->map(fn ($item) => [
|
||||
'src' => 'assets/'.($item->content['filename'] ?? ''),
|
||||
'position' => $item->content['position'] ?? 25,
|
||||
]);
|
||||
|
||||
$footerContent = $items->where('item_type', 'footer')->values()->map(function ($item) {
|
||||
$data = [
|
||||
'headline' => $item->content['headline'] ?? '',
|
||||
'subline' => $item->content['subline'] ?? '',
|
||||
];
|
||||
|
||||
if (! empty($item->content['url'])) {
|
||||
$data['url'] = $item->content['url'];
|
||||
}
|
||||
|
||||
return $data;
|
||||
});
|
||||
|
||||
return [
|
||||
'type' => 'video-display',
|
||||
'version_name' => $version->name,
|
||||
'videoPlaylist' => $videos,
|
||||
'footerContent' => $footerContent,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function b2inData($version, $items): array
|
||||
{
|
||||
$mediaItems = $items->where('item_type', 'media')->values()->map(fn ($item) => [
|
||||
'id' => $item->id,
|
||||
'category' => $item->content['category'] ?? 'immobilien',
|
||||
'media_type' => $item->content['media_type'] ?? 'image',
|
||||
'media_url' => $item->content['media_url'] ?? '',
|
||||
'headline' => $item->content['headline'] ?? '',
|
||||
'subline' => $item->content['subline'] ?? '',
|
||||
'duration_seconds' => $item->content['duration_seconds'] ?? 10,
|
||||
'sort_order' => $item->sort_order,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
return [
|
||||
'type' => 'b2in',
|
||||
'version_name' => $version->name,
|
||||
'settings' => $version->settings ?? [],
|
||||
'items' => $mediaItems,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function offersData($version, $items): array
|
||||
{
|
||||
$slides = $items->where('item_type', 'slide')->values()->map(fn ($item) => [
|
||||
'type' => $item->content['type'] ?? 'product-hero',
|
||||
'duration' => $item->content['duration'] ?? 8000,
|
||||
'image_url' => $item->content['image_url'] ?? '',
|
||||
'badge_text' => $item->content['badge_text'] ?? '',
|
||||
'eyebrow' => $item->content['eyebrow'] ?? '',
|
||||
'title' => $item->content['title'] ?? '',
|
||||
'subline' => $item->content['subline'] ?? '',
|
||||
'price' => $item->content['price'] ?? '',
|
||||
'original_price' => $item->content['original_price'] ?? '',
|
||||
'tag_text' => $item->content['tag_text'] ?? '',
|
||||
'bullets' => $item->content['bullets'] ?? [],
|
||||
'disclaimer' => $item->content['disclaimer'] ?? '',
|
||||
'qr_url' => $item->content['qr_url'] ?? '',
|
||||
'qr_title' => $item->content['qr_title'] ?? '',
|
||||
'contact' => $item->content['contact'] ?? '',
|
||||
'show_brand_text' => $item->content['show_brand_text'] ?? false,
|
||||
'brand_tagline' => $item->content['brand_tagline'] ?? '',
|
||||
]);
|
||||
|
||||
return [
|
||||
'type' => 'offers',
|
||||
'version_name' => $version->name,
|
||||
'settings' => $version->settings ?? [],
|
||||
'slides' => $slides,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
50
app/Http/Controllers/Api/ModulePreviewController.php
Normal file
50
app/Http/Controllers/Api/ModulePreviewController.php
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\DisplayVersion;
|
||||
use App\Models\DisplayVersionItem;
|
||||
use App\Services\DisplayPlaylistConfigBuilder;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
|
||||
class ModulePreviewController extends Controller
|
||||
{
|
||||
public function show(DisplayVersion $module): BinaryFileResponse
|
||||
{
|
||||
return response()->file(public_path('_cabinet/display/index.html'));
|
||||
}
|
||||
|
||||
public function showItem(DisplayVersion $module, DisplayVersionItem $item): BinaryFileResponse
|
||||
{
|
||||
abort_unless($item->display_version_id === $module->id, 404);
|
||||
|
||||
return response()->file(public_path('_cabinet/display/index.html'));
|
||||
}
|
||||
|
||||
public function config(DisplayVersion $module, DisplayPlaylistConfigBuilder $configBuilder): JsonResponse
|
||||
{
|
||||
$module->load('items');
|
||||
|
||||
if (! $module->is_active) {
|
||||
return response()->json(['error' => 'Module preview not configured'], 404);
|
||||
}
|
||||
|
||||
return response()->json($configBuilder->fromModules($module->newCollection([$module])));
|
||||
}
|
||||
|
||||
public function configItem(DisplayVersion $module, DisplayVersionItem $item, DisplayPlaylistConfigBuilder $configBuilder): JsonResponse
|
||||
{
|
||||
abort_unless($item->display_version_id === $module->id, 404);
|
||||
|
||||
if (! $module->is_active) {
|
||||
return response()->json(['error' => 'Module preview not configured'], 404);
|
||||
}
|
||||
|
||||
$item->is_active = true;
|
||||
$module->setRelation('items', $item->newCollection([$item]));
|
||||
|
||||
return response()->json($configBuilder->fromModules($module->newCollection([$module]), $item->updated_at));
|
||||
}
|
||||
}
|
||||
|
|
@ -3,7 +3,12 @@
|
|||
namespace App\Livewire\Admin\Cms;
|
||||
|
||||
use App\Models\Display;
|
||||
use App\Models\DisplayPlaylist;
|
||||
use App\Models\DisplayPlaylistItem;
|
||||
use App\Models\DisplayVersion;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Livewire\Component;
|
||||
|
||||
class DisplayList extends Component
|
||||
|
|
@ -21,17 +26,36 @@ class DisplayList extends Component
|
|||
|
||||
public $displayIsActive = true;
|
||||
|
||||
public $displayIsTest = false;
|
||||
|
||||
public $addVersionSelect = null;
|
||||
|
||||
public function openModal(?int $id = null): void
|
||||
public $editingPlaylistStatus = DisplayPlaylist::STATUS_PUBLISHED;
|
||||
|
||||
public ?string $draftPreviewToken = null;
|
||||
|
||||
public int $previewFrameRefreshCounter = 0;
|
||||
|
||||
public function openModal(?int $id = null, string $playlistStatus = DisplayPlaylist::STATUS_PUBLISHED): void
|
||||
{
|
||||
$this->editingPlaylistStatus = in_array($playlistStatus, [
|
||||
DisplayPlaylist::STATUS_PUBLISHED,
|
||||
DisplayPlaylist::STATUS_DRAFT,
|
||||
], true) ? $playlistStatus : DisplayPlaylist::STATUS_PUBLISHED;
|
||||
|
||||
if ($id) {
|
||||
$display = Display::with('versions')->findOrFail($id);
|
||||
$display = Display::with(['livePlaylist.modules', 'draftPlaylist.modules', 'versions'])->findOrFail($id);
|
||||
$this->displayId = $display->id;
|
||||
$this->displayName = $display->name;
|
||||
$this->displayLocation = $display->location ?? '';
|
||||
$this->selectedVersionIds = $display->versions->pluck('id')->toArray();
|
||||
$this->selectedVersionIds = $this->selectedVersionIdsFor($display);
|
||||
$this->displayIsActive = $display->is_active;
|
||||
$this->displayIsTest = $display->is_test;
|
||||
|
||||
if ($this->editingPlaylistStatus === DisplayPlaylist::STATUS_DRAFT) {
|
||||
$this->draftPreviewToken = $display->ensurePreviewToken();
|
||||
$this->previewFrameRefreshCounter++;
|
||||
}
|
||||
} else {
|
||||
$this->resetForm();
|
||||
}
|
||||
|
|
@ -41,18 +65,28 @@ class DisplayList extends Component
|
|||
|
||||
public function addVersion(?int $versionId = null): void
|
||||
{
|
||||
$id = $versionId ?? $this->addVersionSelect;
|
||||
$id = $versionId ?? $this->addVersionSelect ?? $this->firstAvailableVersionId();
|
||||
|
||||
if ($id && ! in_array((int) $id, $this->selectedVersionIds)) {
|
||||
$this->selectedVersionIds[] = (int) $id;
|
||||
}
|
||||
|
||||
$this->addVersionSelect = null;
|
||||
$this->persistDraftPreviewIfNeeded();
|
||||
}
|
||||
|
||||
private function firstAvailableVersionId(): ?int
|
||||
{
|
||||
return DisplayVersion::active()
|
||||
->whereNotIn('id', $this->selectedVersionIds)
|
||||
->orderBy('name')
|
||||
->value('id');
|
||||
}
|
||||
|
||||
public function removeVersion(int $index): void
|
||||
{
|
||||
array_splice($this->selectedVersionIds, $index, 1);
|
||||
$this->persistDraftPreviewIfNeeded();
|
||||
}
|
||||
|
||||
public function moveVersion(int $index, string $direction): void
|
||||
|
|
@ -66,6 +100,8 @@ class DisplayList extends Component
|
|||
$temp = $this->selectedVersionIds[$index];
|
||||
$this->selectedVersionIds[$index] = $this->selectedVersionIds[$newIndex];
|
||||
$this->selectedVersionIds[$newIndex] = $temp;
|
||||
|
||||
$this->persistDraftPreviewIfNeeded();
|
||||
}
|
||||
|
||||
public function save(): void
|
||||
|
|
@ -83,6 +119,7 @@ class DisplayList extends Component
|
|||
'name' => $this->displayName,
|
||||
'location' => $this->displayLocation ?: null,
|
||||
'is_active' => $this->displayIsActive,
|
||||
'is_test' => $this->displayIsTest,
|
||||
];
|
||||
|
||||
if ($this->displayId) {
|
||||
|
|
@ -94,16 +131,176 @@ class DisplayList extends Component
|
|||
session()->flash('success', 'Display erfolgreich erstellt!');
|
||||
}
|
||||
|
||||
// Sync versions with sort_order
|
||||
$syncData = [];
|
||||
foreach ($this->selectedVersionIds as $sortOrder => $versionId) {
|
||||
$syncData[$versionId] = ['sort_order' => $sortOrder];
|
||||
if ($this->editingPlaylistStatus === DisplayPlaylist::STATUS_DRAFT && $this->displayId) {
|
||||
$this->syncDraftPlaylist($display);
|
||||
$this->draftPreviewToken = $display->ensurePreviewToken();
|
||||
$this->previewFrameRefreshCounter++;
|
||||
} else {
|
||||
$this->syncPublishedPlaylist($display);
|
||||
$this->syncLegacyPivot($display, $this->selectedVersionIds);
|
||||
}
|
||||
$display->versions()->sync($syncData);
|
||||
|
||||
$this->closeModal();
|
||||
}
|
||||
|
||||
public function createDraft(int $displayId): void
|
||||
{
|
||||
$display = Display::with(['livePlaylist.modules', 'draftPlaylist'])->findOrFail($displayId);
|
||||
|
||||
if ($display->draftPlaylist) {
|
||||
session()->flash('success', 'Für dieses Display existiert bereits ein Entwurf.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$draft = $display->playlists()->create([
|
||||
'status' => DisplayPlaylist::STATUS_DRAFT,
|
||||
'published_at' => null,
|
||||
'published_by' => null,
|
||||
]);
|
||||
|
||||
$this->syncPlaylistItems($draft, $this->moduleIdsForPlaylist($display->livePlaylist));
|
||||
$display->ensurePreviewToken();
|
||||
|
||||
session()->flash('success', 'Entwurf wurde aus dem Live-Stand angelegt.');
|
||||
}
|
||||
|
||||
public function discardDraft(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->draftPlaylist->delete();
|
||||
|
||||
session()->flash('success', 'Entwurf wurde verworfen.');
|
||||
}
|
||||
|
||||
public function publishDraft(int $displayId): void
|
||||
{
|
||||
$display = Display::with(['draftPlaylist.modules'])->findOrFail($displayId);
|
||||
|
||||
if (! $display->draftPlaylist) {
|
||||
session()->flash('success', 'Für dieses Display gibt es keinen Entwurf zum Veröffentlichen.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$publishedPlaylist = DB::transaction(function () use ($display): DisplayPlaylist {
|
||||
$display->livePlaylist()->delete();
|
||||
|
||||
$display->draftPlaylist->update([
|
||||
'status' => DisplayPlaylist::STATUS_PUBLISHED,
|
||||
'published_at' => now(),
|
||||
'published_by' => Auth::id(),
|
||||
]);
|
||||
|
||||
return $display->draftPlaylist->fresh('modules');
|
||||
});
|
||||
|
||||
$this->syncLegacyPivot($display, $this->moduleIdsForPlaylist($publishedPlaylist));
|
||||
|
||||
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
|
||||
{
|
||||
$playlist = $display->playlists()->firstOrCreate(
|
||||
['status' => DisplayPlaylist::STATUS_PUBLISHED],
|
||||
['published_at' => now(), 'published_by' => Auth::id()]
|
||||
);
|
||||
|
||||
$playlist->update([
|
||||
'published_at' => now(),
|
||||
'published_by' => Auth::id(),
|
||||
]);
|
||||
|
||||
$this->syncPlaylistItems($playlist, $this->selectedVersionIds);
|
||||
}
|
||||
|
||||
private function syncDraftPlaylist(Display $display): void
|
||||
{
|
||||
$playlist = $display->playlists()->firstOrCreate(
|
||||
['status' => DisplayPlaylist::STATUS_DRAFT],
|
||||
['published_at' => null, 'published_by' => null]
|
||||
);
|
||||
|
||||
$this->syncPlaylistItems($playlist, $this->selectedVersionIds);
|
||||
}
|
||||
|
||||
private function persistDraftPreviewIfNeeded(): void
|
||||
{
|
||||
if ($this->editingPlaylistStatus !== DisplayPlaylist::STATUS_DRAFT || ! $this->displayId) {
|
||||
return;
|
||||
}
|
||||
|
||||
$display = Display::findOrFail($this->displayId);
|
||||
|
||||
$this->syncDraftPlaylist($display);
|
||||
$this->draftPreviewToken = $display->ensurePreviewToken();
|
||||
$this->previewFrameRefreshCounter++;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int> $versionIds
|
||||
*/
|
||||
private function syncPlaylistItems(DisplayPlaylist $playlist, array $versionIds): void
|
||||
{
|
||||
$playlist->items()->delete();
|
||||
|
||||
foreach (array_values($versionIds) as $sortOrder => $versionId) {
|
||||
DisplayPlaylistItem::query()->create([
|
||||
'display_playlist_id' => $playlist->id,
|
||||
'display_version_id' => $versionId,
|
||||
'sort_order' => $sortOrder,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int>
|
||||
*/
|
||||
private function moduleIdsForPlaylist(?DisplayPlaylist $playlist): array
|
||||
{
|
||||
if (! $playlist) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$playlist->loadMissing('modules');
|
||||
|
||||
return $playlist->modules->pluck('id')->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int>
|
||||
*/
|
||||
private function selectedVersionIdsFor(Display $display): array
|
||||
{
|
||||
if ($this->editingPlaylistStatus === DisplayPlaylist::STATUS_DRAFT) {
|
||||
return $this->moduleIdsForPlaylist($display->draftPlaylist);
|
||||
}
|
||||
|
||||
return $this->moduleIdsForPlaylist($display->livePlaylist)
|
||||
?: $display->versions->pluck('id')->all();
|
||||
}
|
||||
|
||||
public function deleteDisplay(int $id): void
|
||||
{
|
||||
$display = Display::findOrFail($id);
|
||||
|
|
@ -132,12 +329,17 @@ class DisplayList extends Component
|
|||
$this->displayLocation = '';
|
||||
$this->selectedVersionIds = [];
|
||||
$this->displayIsActive = true;
|
||||
$this->displayIsTest = false;
|
||||
$this->addVersionSelect = null;
|
||||
$this->editingPlaylistStatus = DisplayPlaylist::STATUS_PUBLISHED;
|
||||
$this->draftPreviewToken = null;
|
||||
$this->previewFrameRefreshCounter = 0;
|
||||
}
|
||||
|
||||
public function render()
|
||||
public function render(): View
|
||||
{
|
||||
$displays = Display::with('versions')
|
||||
$displays = Display::with(['livePlaylist.modules', 'draftPlaylist.modules'])
|
||||
->orderByDesc('is_test')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@ class DisplayMediaPicker extends Component
|
|||
{
|
||||
$this->validate([
|
||||
'quickUploads' => 'nullable|array|max:5',
|
||||
'quickUploads.*' => 'file|mimes:jpeg,jpg,png,gif,webp,mp4,webm,mov|max:204800',
|
||||
'quickUploads.*' => 'file|mimes:jpeg,jpg,png,gif,webp,svg,mp4,webm,mov|max:204800',
|
||||
]);
|
||||
|
||||
$service = app(DisplayMediaService::class);
|
||||
|
|
|
|||
|
|
@ -101,11 +101,13 @@ class DisplayVersionEditor extends Component
|
|||
/** @var array<string> */
|
||||
public array $availableVideos = [];
|
||||
|
||||
public int $previewFrameRefreshCounter = 0;
|
||||
|
||||
public function mount(DisplayVersion $displayVersion): void
|
||||
{
|
||||
$this->version = $displayVersion;
|
||||
$this->versionName = $displayVersion->name;
|
||||
$this->settings = $displayVersion->settings ?? [];
|
||||
$this->settings = $this->settingsWithDefaults();
|
||||
|
||||
if ($this->version->type === DisplayVersionType::VideoDisplay) {
|
||||
$this->loadAvailableVideos();
|
||||
|
|
@ -127,10 +129,11 @@ class DisplayVersionEditor extends Component
|
|||
|
||||
public function toggleTheme(): void
|
||||
{
|
||||
$settings = $this->version->settings ?? [];
|
||||
$settings = $this->settingsWithDefaults();
|
||||
$settings['theme'] = ($settings['theme'] ?? 'dark') === 'dark' ? 'light' : 'dark';
|
||||
$this->version->update(['settings' => $settings]);
|
||||
$this->settings = $settings;
|
||||
$this->refreshModulePreview();
|
||||
}
|
||||
|
||||
public function saveName(): void
|
||||
|
|
@ -140,6 +143,7 @@ class DisplayVersionEditor extends Component
|
|||
]);
|
||||
|
||||
$this->version->update(['name' => $this->versionName]);
|
||||
$this->refreshModulePreview();
|
||||
session()->flash('success', 'Name aktualisiert!');
|
||||
}
|
||||
|
||||
|
|
@ -149,7 +153,7 @@ class DisplayVersionEditor extends Component
|
|||
|
||||
public function openSettingsModal(): void
|
||||
{
|
||||
$this->settings = $this->version->settings ?? [];
|
||||
$this->settings = $this->settingsWithDefaults();
|
||||
$this->showSettingsModal = true;
|
||||
}
|
||||
|
||||
|
|
@ -157,6 +161,7 @@ class DisplayVersionEditor extends Component
|
|||
{
|
||||
$this->version->update(['settings' => $this->settings]);
|
||||
$this->showSettingsModal = false;
|
||||
$this->refreshModulePreview();
|
||||
session()->flash('success', 'Einstellungen gespeichert!');
|
||||
}
|
||||
|
||||
|
|
@ -197,7 +202,7 @@ class DisplayVersionEditor extends Component
|
|||
->where('item_type', $this->itemType)
|
||||
->max('sort_order') ?? -1;
|
||||
|
||||
DisplayVersionItem::create([
|
||||
$item = DisplayVersionItem::create([
|
||||
'display_version_id' => $this->version->id,
|
||||
'item_type' => $this->itemType,
|
||||
'content' => $content,
|
||||
|
|
@ -207,12 +212,17 @@ class DisplayVersionEditor extends Component
|
|||
session()->flash('success', 'Inhalt hinzugefügt!');
|
||||
}
|
||||
|
||||
$this->closeItemModal();
|
||||
$this->itemId = $item->id;
|
||||
$this->itemType = $item->item_type;
|
||||
$this->loadItemContent($item->fresh());
|
||||
$this->showItemModal = true;
|
||||
$this->refreshModulePreview();
|
||||
}
|
||||
|
||||
public function deleteItem(int $id): void
|
||||
{
|
||||
DisplayVersionItem::findOrFail($id)->delete();
|
||||
$this->refreshModulePreview();
|
||||
session()->flash('success', 'Inhalt gelöscht!');
|
||||
}
|
||||
|
||||
|
|
@ -220,6 +230,7 @@ class DisplayVersionEditor extends Component
|
|||
{
|
||||
$item = DisplayVersionItem::findOrFail($id);
|
||||
$item->update(['is_active' => ! $item->is_active]);
|
||||
$this->refreshModulePreview();
|
||||
}
|
||||
|
||||
public function moveItem(int $id, string $direction): void
|
||||
|
|
@ -235,9 +246,24 @@ class DisplayVersionEditor extends Component
|
|||
if ($swapItem) {
|
||||
$item->update(['sort_order' => $swapItem->sort_order]);
|
||||
$swapItem->update(['sort_order' => $currentOrder]);
|
||||
$this->refreshModulePreview();
|
||||
}
|
||||
}
|
||||
|
||||
public function modulePreviewUrl(): string
|
||||
{
|
||||
return url('/preview/module/'.$this->version->id).'?refresh='.$this->previewFrameRefreshCounter;
|
||||
}
|
||||
|
||||
public function itemPreviewUrl(): string
|
||||
{
|
||||
if (! $this->itemId) {
|
||||
return $this->modulePreviewUrl();
|
||||
}
|
||||
|
||||
return url('/preview/module/'.$this->version->id.'/item/'.$this->itemId).'?refresh='.$this->previewFrameRefreshCounter;
|
||||
}
|
||||
|
||||
#[On('display-media-selected')]
|
||||
public function onDisplayMediaSelected(string $field, ?int $mediaId, ?string $url): void
|
||||
{
|
||||
|
|
@ -249,6 +275,8 @@ class DisplayVersionEditor extends Component
|
|||
'videoFilename' => $this->videoFilename = $url,
|
||||
'mediaUrl' => $this->mediaUrl = $url,
|
||||
'slideImageUrl' => $this->slideImageUrl = $url,
|
||||
'settings.header_logo_url' => $this->settings['header_logo_url'] = $url,
|
||||
'settings.logo_url' => $this->settings['logo_url'] = $url,
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
|
@ -442,6 +470,58 @@ class DisplayVersionEditor extends Component
|
|||
$this->slideIsActive = true;
|
||||
}
|
||||
|
||||
private function refreshModulePreview(): void
|
||||
{
|
||||
$this->previewFrameRefreshCounter++;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function settingsWithDefaults(): array
|
||||
{
|
||||
return array_replace_recursive($this->defaultSettings(), $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()
|
||||
{
|
||||
$items = $this->version->items()->get()->groupBy('item_type');
|
||||
|
|
|
|||
|
|
@ -42,10 +42,10 @@ class DisplayVersionList extends Component
|
|||
$this->newName = '';
|
||||
$this->newType = '';
|
||||
|
||||
session()->flash('success', 'Version "'.$version->name.'" wurde erstellt!');
|
||||
session()->flash('success', 'Modul "'.$version->name.'" wurde erstellt!');
|
||||
|
||||
$this->redirect(
|
||||
route('admin.cms.display-version-edit', $version),
|
||||
route('admin.cms.display-module-edit', $version),
|
||||
navigate: true
|
||||
);
|
||||
}
|
||||
|
|
@ -56,7 +56,7 @@ class DisplayVersionList extends Component
|
|||
$name = $version->name;
|
||||
$version->delete();
|
||||
|
||||
session()->flash('success', 'Version "'.$name.'" wurde gelöscht!');
|
||||
session()->flash('success', 'Modul "'.$name.'" wurde gelöscht!');
|
||||
}
|
||||
|
||||
public function toggleActive(int $id): void
|
||||
|
|
@ -73,8 +73,12 @@ class DisplayVersionList extends Component
|
|||
return match ($type) {
|
||||
'b2in' => [
|
||||
'theme' => 'dark',
|
||||
'header_logo_url' => '../assets/b2in-logo-positive.svg',
|
||||
'header_claim' => 'Connecting Design & Property',
|
||||
'footer_name' => '',
|
||||
'footer_url' => '',
|
||||
'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],
|
||||
|
|
@ -82,8 +86,17 @@ class DisplayVersionList extends Component
|
|||
],
|
||||
'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 => [],
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,9 @@ namespace App\Models;
|
|||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class Display extends Model
|
||||
{
|
||||
|
|
@ -15,19 +18,85 @@ class Display extends Model
|
|||
'name',
|
||||
'location',
|
||||
'is_active',
|
||||
'is_test',
|
||||
'preview_token',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'is_active' => 'boolean',
|
||||
'is_test' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @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>
|
||||
*/
|
||||
public function playlists(): HasMany
|
||||
{
|
||||
return $this->hasMany(DisplayPlaylist::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return HasOne<DisplayPlaylist, $this>
|
||||
*/
|
||||
public function livePlaylist(): HasOne
|
||||
{
|
||||
return $this->hasOne(DisplayPlaylist::class)
|
||||
->where('status', DisplayPlaylist::STATUS_PUBLISHED);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return HasOne<DisplayPlaylist, $this>
|
||||
*/
|
||||
public function draftPlaylist(): HasOne
|
||||
{
|
||||
return $this->hasOne(DisplayPlaylist::class)
|
||||
->where('status', DisplayPlaylist::STATUS_DRAFT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Liefert die Module der aktuell veröffentlichten Bespielung in Reihenfolge.
|
||||
*/
|
||||
public function liveModules(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(
|
||||
DisplayVersion::class,
|
||||
'display_playlist_items',
|
||||
'display_playlist_id',
|
||||
'display_version_id'
|
||||
)
|
||||
->wherePivotIn(
|
||||
'display_playlist_id',
|
||||
DisplayPlaylist::query()
|
||||
->where('display_id', $this->id ?? 0)
|
||||
->where('status', DisplayPlaylist::STATUS_PUBLISHED)
|
||||
->select('id')
|
||||
)
|
||||
->withPivot(['sort_order', 'id'])
|
||||
->withTimestamps()
|
||||
->orderByPivot('sort_order');
|
||||
}
|
||||
|
||||
public function ensurePreviewToken(): string
|
||||
{
|
||||
if (! $this->preview_token) {
|
||||
$this->preview_token = Str::random(40);
|
||||
$this->save();
|
||||
}
|
||||
|
||||
return $this->preview_token;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
89
app/Models/DisplayPlaylist.php
Normal file
89
app/Models/DisplayPlaylist.php
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class DisplayPlaylist extends Model
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\DisplayPlaylistFactory> */
|
||||
use HasFactory;
|
||||
|
||||
public const STATUS_PUBLISHED = 'published';
|
||||
|
||||
public const STATUS_DRAFT = 'draft';
|
||||
|
||||
protected $fillable = [
|
||||
'display_id',
|
||||
'status',
|
||||
'published_at',
|
||||
'published_by',
|
||||
'notes',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'published_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function display(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Display::class);
|
||||
}
|
||||
|
||||
public function publisher(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'published_by');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return HasMany<DisplayPlaylistItem, $this>
|
||||
*/
|
||||
public function items(): HasMany
|
||||
{
|
||||
return $this->hasMany(DisplayPlaylistItem::class)->orderBy('sort_order');
|
||||
}
|
||||
|
||||
/**
|
||||
* Die der Bespielung zugeordneten Module in korrekter Reihenfolge.
|
||||
*/
|
||||
public function modules(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(
|
||||
DisplayVersion::class,
|
||||
'display_playlist_items',
|
||||
'display_playlist_id',
|
||||
'display_version_id'
|
||||
)
|
||||
->withPivot(['sort_order', 'id'])
|
||||
->withTimestamps()
|
||||
->orderByPivot('sort_order');
|
||||
}
|
||||
|
||||
public function isPublished(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_PUBLISHED;
|
||||
}
|
||||
|
||||
public function isDraft(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_DRAFT;
|
||||
}
|
||||
|
||||
public function scopePublished(Builder $query): Builder
|
||||
{
|
||||
return $query->where('status', self::STATUS_PUBLISHED);
|
||||
}
|
||||
|
||||
public function scopeDraft(Builder $query): Builder
|
||||
{
|
||||
return $query->where('status', self::STATUS_DRAFT);
|
||||
}
|
||||
}
|
||||
36
app/Models/DisplayPlaylistItem.php
Normal file
36
app/Models/DisplayPlaylistItem.php
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class DisplayPlaylistItem extends Model
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\DisplayPlaylistItemFactory> */
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'display_playlist_id',
|
||||
'display_version_id',
|
||||
'sort_order',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'sort_order' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
public function playlist(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(DisplayPlaylist::class, 'display_playlist_id');
|
||||
}
|
||||
|
||||
public function module(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(DisplayVersion::class, 'display_version_id');
|
||||
}
|
||||
}
|
||||
199
app/Services/DisplayPlaylistConfigBuilder.php
Normal file
199
app/Services/DisplayPlaylistConfigBuilder.php
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\DisplayPlaylist;
|
||||
use App\Models\DisplayVersion;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
|
||||
class DisplayPlaylistConfigBuilder
|
||||
{
|
||||
/**
|
||||
* @return array{playlist: array<int, array<string, mixed>>, updated_at: string|null}
|
||||
*/
|
||||
public function fromPlaylist(DisplayPlaylist $playlist): array
|
||||
{
|
||||
$playlist->loadMissing('modules.items');
|
||||
|
||||
return $this->fromModules($playlist->modules, $playlist->updated_at);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, DisplayVersion> $modules
|
||||
* @return array{playlist: array<int, array<string, mixed>>, updated_at: string|null}
|
||||
*/
|
||||
public function fromModules(Collection $modules, mixed $fallbackUpdatedAt = null): array
|
||||
{
|
||||
$playlist = $modules
|
||||
->loadMissing('items')
|
||||
->map(fn (DisplayVersion $module) => $this->moduleData($module))
|
||||
->filter()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
return [
|
||||
'playlist' => $playlist,
|
||||
'updated_at' => $this->updatedAt($modules, $fallbackUpdatedAt),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function moduleData(DisplayVersion $module): ?array
|
||||
{
|
||||
$items = $module->items
|
||||
->where('is_active', true)
|
||||
->sortBy('sort_order')
|
||||
->values();
|
||||
|
||||
return match ($module->type->value) {
|
||||
'video-display' => $this->videoDisplayData($module, $items),
|
||||
'b2in' => $this->b2inData($module, $items),
|
||||
'offers' => $this->offersData($module, $items),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, DisplayVersion> $modules
|
||||
*/
|
||||
private function updatedAt(Collection $modules, mixed $fallbackUpdatedAt = null): ?string
|
||||
{
|
||||
$timestamps = collect([$fallbackUpdatedAt])
|
||||
->merge($modules->pluck('updated_at'))
|
||||
->merge($modules->flatMap(fn (DisplayVersion $module) => $module->items->pluck('updated_at')))
|
||||
->filter();
|
||||
|
||||
return $timestamps->max()?->toIso8601String();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, \App\Models\DisplayVersionItem> $items
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function videoDisplayData(DisplayVersion $module, Collection $items): array
|
||||
{
|
||||
$videos = $items->where('item_type', 'video')->values()->map(fn ($item) => [
|
||||
'src' => $this->mediaSource($item->content['filename'] ?? ''),
|
||||
'position' => $item->content['position'] ?? 25,
|
||||
]);
|
||||
|
||||
$footerContent = $items->where('item_type', 'footer')->values()->map(function ($item) {
|
||||
$data = [
|
||||
'headline' => $item->content['headline'] ?? '',
|
||||
'subline' => $item->content['subline'] ?? '',
|
||||
];
|
||||
|
||||
if (! empty($item->content['url'])) {
|
||||
$data['url'] = $item->content['url'];
|
||||
}
|
||||
|
||||
return $data;
|
||||
});
|
||||
|
||||
return [
|
||||
'type' => 'video-display',
|
||||
'version_name' => $module->name,
|
||||
'settings' => array_replace([
|
||||
'qr_label' => 'Website',
|
||||
], $module->settings ?? []),
|
||||
'videoPlaylist' => $videos,
|
||||
'footerContent' => $footerContent,
|
||||
];
|
||||
}
|
||||
|
||||
private function mediaSource(string $source): string
|
||||
{
|
||||
if ($source === '' || str_starts_with($source, 'http') || str_starts_with($source, '/') || str_starts_with($source, '../')) {
|
||||
return $source;
|
||||
}
|
||||
|
||||
return 'assets/'.$source;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, \App\Models\DisplayVersionItem> $items
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function b2inData(DisplayVersion $module, Collection $items): array
|
||||
{
|
||||
$mediaItems = $items->where('item_type', 'media')->values()->map(fn ($item) => [
|
||||
'id' => $item->id,
|
||||
'category' => $item->content['category'] ?? 'immobilien',
|
||||
'media_type' => $item->content['media_type'] ?? 'image',
|
||||
'media_url' => $item->content['media_url'] ?? '',
|
||||
'headline' => $item->content['headline'] ?? '',
|
||||
'subline' => $item->content['subline'] ?? '',
|
||||
'duration_seconds' => $item->content['duration_seconds'] ?? 10,
|
||||
'sort_order' => $item->sort_order,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
return [
|
||||
'type' => 'b2in',
|
||||
'version_name' => $module->name,
|
||||
'settings' => array_replace_recursive([
|
||||
'theme' => 'dark',
|
||||
'header_logo_url' => '../assets/b2in-logo-positive.svg',
|
||||
'header_claim' => 'Connecting Design & Property',
|
||||
'footer_url' => 'B2in.eu',
|
||||
'footer_name' => '',
|
||||
'footer_prefix' => 'by',
|
||||
'qr_url' => '',
|
||||
'transition' => [
|
||||
'type' => 'crossfade',
|
||||
'duration_ms' => 800,
|
||||
],
|
||||
'default_image_duration' => 10,
|
||||
], $module->settings ?? []),
|
||||
'items' => $mediaItems,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, \App\Models\DisplayVersionItem> $items
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function offersData(DisplayVersion $module, Collection $items): array
|
||||
{
|
||||
$slides = $items->where('item_type', 'slide')->values()->map(fn ($item) => [
|
||||
'type' => $item->content['type'] ?? 'product-hero',
|
||||
'duration' => $item->content['duration'] ?? 8000,
|
||||
'image_url' => $item->content['image_url'] ?? '',
|
||||
'badge_text' => $item->content['badge_text'] ?? '',
|
||||
'eyebrow' => $item->content['eyebrow'] ?? '',
|
||||
'title' => $item->content['title'] ?? '',
|
||||
'subline' => $item->content['subline'] ?? '',
|
||||
'price' => $item->content['price'] ?? '',
|
||||
'original_price' => $item->content['original_price'] ?? '',
|
||||
'tag_text' => $item->content['tag_text'] ?? '',
|
||||
'bullets' => $item->content['bullets'] ?? [],
|
||||
'disclaimer' => $item->content['disclaimer'] ?? '',
|
||||
'qr_url' => $item->content['qr_url'] ?? '',
|
||||
'qr_title' => $item->content['qr_title'] ?? '',
|
||||
'contact' => $item->content['contact'] ?? '',
|
||||
'show_brand_text' => $item->content['show_brand_text'] ?? false,
|
||||
'brand_tagline' => $item->content['brand_tagline'] ?? '',
|
||||
]);
|
||||
|
||||
return [
|
||||
'type' => 'offers',
|
||||
'version_name' => $module->name,
|
||||
'settings' => array_replace_recursive([
|
||||
'loop' => true,
|
||||
'logo_url' => '../logo-cabinet-300.png',
|
||||
'brand_text' => 'Bielefeld',
|
||||
'footer_claim' => '',
|
||||
'footer_url' => '',
|
||||
'qr_default_title' => 'Kontakt',
|
||||
'qr_subtitle' => 'QR scannen',
|
||||
'transition' => [
|
||||
'type' => 'fade',
|
||||
'duration' => 600,
|
||||
],
|
||||
], $module->settings ?? []),
|
||||
'slides' => $slides,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -30,6 +30,17 @@ class DisplayFactory extends Factory
|
|||
'name' => fake()->words(2, true),
|
||||
'location' => fake()->optional()->words(3, true),
|
||||
'is_active' => true,
|
||||
'is_test' => false,
|
||||
'preview_token' => null,
|
||||
];
|
||||
}
|
||||
|
||||
public function test(): static
|
||||
{
|
||||
return $this->state(fn () => [
|
||||
'is_test' => true,
|
||||
'name' => 'Test-Display',
|
||||
'location' => 'Vorschau / Test',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
51
database/factories/DisplayPlaylistFactory.php
Normal file
51
database/factories/DisplayPlaylistFactory.php
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\Display;
|
||||
use App\Models\DisplayPlaylist;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @template TModel of \App\Models\DisplayPlaylist
|
||||
*
|
||||
* @extends \Illuminate\Database\Eloquent\Factories\Factory<TModel>
|
||||
*/
|
||||
class DisplayPlaylistFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* @var class-string<TModel>
|
||||
*/
|
||||
protected $model = DisplayPlaylist::class;
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'display_id' => Display::factory(),
|
||||
'status' => DisplayPlaylist::STATUS_PUBLISHED,
|
||||
'published_at' => now(),
|
||||
'published_by' => null,
|
||||
'notes' => null,
|
||||
];
|
||||
}
|
||||
|
||||
public function draft(): static
|
||||
{
|
||||
return $this->state(fn () => [
|
||||
'status' => DisplayPlaylist::STATUS_DRAFT,
|
||||
'published_at' => null,
|
||||
'published_by' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
public function published(): static
|
||||
{
|
||||
return $this->state(fn () => [
|
||||
'status' => DisplayPlaylist::STATUS_PUBLISHED,
|
||||
'published_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
33
database/factories/DisplayPlaylistItemFactory.php
Normal file
33
database/factories/DisplayPlaylistItemFactory.php
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\DisplayPlaylist;
|
||||
use App\Models\DisplayPlaylistItem;
|
||||
use App\Models\DisplayVersion;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @template TModel of \App\Models\DisplayPlaylistItem
|
||||
*
|
||||
* @extends \Illuminate\Database\Eloquent\Factories\Factory<TModel>
|
||||
*/
|
||||
class DisplayPlaylistItemFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* @var class-string<TModel>
|
||||
*/
|
||||
protected $model = DisplayPlaylistItem::class;
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'display_playlist_id' => DisplayPlaylist::factory(),
|
||||
'display_version_id' => DisplayVersion::factory(),
|
||||
'sort_order' => 0,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* Erweitert die displays-Tabelle um:
|
||||
* - is_test: Kennzeichnet ein Display als Test-Display (eigene Kachel im Admin,
|
||||
* beliebige Module/Bespielungen können dort gefahrlos kombiniert werden).
|
||||
* - preview_token: Eindeutiger Token für die Vorschau einer Entwurfs-Bespielung
|
||||
* über /preview/{token} – ohne Login.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('displays', function (Blueprint $table) {
|
||||
$table->boolean('is_test')->default(false)->after('is_active');
|
||||
$table->string('preview_token', 64)->nullable()->unique()->after('is_test');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('displays', function (Blueprint $table) {
|
||||
$table->dropUnique(['preview_token']);
|
||||
$table->dropColumn(['is_test', 'preview_token']);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* Eine Display-Bespielung – pro Display existiert maximal eine Live-Bespielung
|
||||
* (status = published) und optional eine Entwurfs-Bespielung (status = draft).
|
||||
*
|
||||
* Der Player liest immer die published-Playlist. Die Vorschau-Route greift
|
||||
* über den preview_token des Displays auf die draft-Playlist zu.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('display_playlists', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('display_id')->constrained()->cascadeOnDelete();
|
||||
$table->string('status'); // 'published' | 'draft'
|
||||
$table->timestamp('published_at')->nullable();
|
||||
$table->foreignId('published_by')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->text('notes')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['display_id', 'status']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('display_playlists');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* Eintrag in einer Display-Bespielung. Ersetzt funktional die alte
|
||||
* Pivot-Tabelle display_display_version. display_versions selbst bleibt
|
||||
* unverändert – Module sind weiterhin wiederverwendbar (shared) zwischen
|
||||
* Displays.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('display_playlist_items', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('display_playlist_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('display_version_id')->constrained()->cascadeOnDelete();
|
||||
$table->unsignedInteger('sort_order')->default(0);
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['display_playlist_id', 'sort_order']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('display_playlist_items');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* Überführt bestehende Pivot-Daten aus display_display_version in die neue
|
||||
* Struktur display_playlists + display_playlist_items. Pro Display wird eine
|
||||
* Live-Bespielung (status = published) erzeugt und die bestehende Reihenfolge
|
||||
* 1:1 übernommen. Die alte Pivot-Tabelle bleibt erhalten und wird erst in
|
||||
* Phase 7 dropped (Bestandsfunktionen müssen weiterlaufen).
|
||||
*
|
||||
* Idempotent: Existiert bereits eine Published-Playlist für ein Display, wird
|
||||
* sie übersprungen.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
if (! Schema::hasTable('display_display_version')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$pivots = DB::table('display_display_version')
|
||||
->orderBy('display_id')
|
||||
->orderBy('sort_order')
|
||||
->get()
|
||||
->groupBy('display_id');
|
||||
|
||||
$now = now();
|
||||
|
||||
foreach ($pivots as $displayId => $entries) {
|
||||
$existing = DB::table('display_playlists')
|
||||
->where('display_id', $displayId)
|
||||
->where('status', 'published')
|
||||
->first();
|
||||
|
||||
if ($existing) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$playlistId = DB::table('display_playlists')->insertGetId([
|
||||
'display_id' => $displayId,
|
||||
'status' => 'published',
|
||||
'published_at' => $now,
|
||||
'published_by' => null,
|
||||
'notes' => 'Initial-Import aus display_display_version',
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
foreach ($entries as $entry) {
|
||||
DB::table('display_playlist_items')->insert([
|
||||
'display_playlist_id' => $playlistId,
|
||||
'display_version_id' => $entry->display_version_id,
|
||||
'sort_order' => $entry->sort_order,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
DB::table('display_playlist_items')->delete();
|
||||
DB::table('display_playlists')->delete();
|
||||
}
|
||||
};
|
||||
402
dev/displays-11-05-2026/00-entwicklungskonzept.md
Normal file
402
dev/displays-11-05-2026/00-entwicklungskonzept.md
Normal file
|
|
@ -0,0 +1,402 @@
|
|||
# Cabinet Displays – Entwicklungskonzept
|
||||
|
||||
**Datum:** 11.05.2026
|
||||
**Autor:** Konzept-Phase
|
||||
**Status:** Freigegeben – Umsetzung gestartet am 11.05.2026
|
||||
|
||||
---
|
||||
|
||||
## 1. Ausgangslage
|
||||
|
||||
### 1.1 Was es heute gibt
|
||||
|
||||
Im Admin-Portal unter `portal.b2in.test/admin/cms/` existiert der Bereich **Store Displays** mit folgenden Unterseiten:
|
||||
|
||||
| Pfad | Bedeutung |
|
||||
|---|---|
|
||||
| `cms/display-dashboard` | Übersicht / Einstieg |
|
||||
| `cms/display-media` | Mediathek (eigene Display-Mediathek, getrennt von Flux CMS) |
|
||||
| `cms/display-versions` | Inhalts-„Versionen" |
|
||||
| `cms/display-versions/{id}/edit` | Editor für eine Version |
|
||||
| `cms/displays` | Physische Displays + Playlist-Zuweisung |
|
||||
| `cms/cabinet-tablet` | Info-Tablet (Öffnungszeiten/Status) |
|
||||
|
||||
### 1.2 Aktuelles Datenmodell
|
||||
|
||||
```
|
||||
displays (5 Datensätze live)
|
||||
└── m:n via display_display_version (sort_order = Playlist-Reihenfolge)
|
||||
└── display_versions (5 Datensätze live)
|
||||
├── type: video-display | b2in | offers
|
||||
├── settings: JSON
|
||||
└── 1:n display_version_items (17 Datensätze live)
|
||||
├── item_type: video | footer | media | slide
|
||||
└── content: JSON
|
||||
```
|
||||
|
||||
### 1.3 Echte Live-Daten (Stand heute)
|
||||
|
||||
**5 Displays:**
|
||||
|
||||
| # | Name | Standort | Playlist (Reihenfolge) |
|
||||
|---|---|---|---|
|
||||
| 1 | Display 1 – Eingang | Schaufenster rechts | Schaufenster Video → B2In Immobilien Dark |
|
||||
| 2 | Display 2 – Mitte | Schaufenster Mitte | Angebote Schauraum |
|
||||
| 3 | Display 3 – Rechts | Schaufenster rechts | B2In Immobilien Light → Angebote Schauraum → Schaufenster Video |
|
||||
| 4 | Display 4 – Innen | Schauraum | B2In Immobilien Dark |
|
||||
| 5 | Kundenstopper | Draußen | Sideboard Goya → Schaufenster Video |
|
||||
|
||||
**5 Versionen:**
|
||||
|
||||
| # | Name | Typ | Items | Verwendet in |
|
||||
|---|---|---|---|---|
|
||||
| 1 | Schaufenster Video | video-display | 6 | 3 Displays |
|
||||
| 2 | B2In Immobilien Dark | b2in | 3 | 2 Displays |
|
||||
| 3 | B2In Immobilien Light | b2in | 3 | 1 Display |
|
||||
| 4 | Angebote Schauraum | offers | 4 | 2 Displays |
|
||||
| 5 | Sideboard Goya | offers | 1 | 1 Display |
|
||||
|
||||
→ Dieser Bestand muss **migriert**, nicht verworfen werden.
|
||||
|
||||
### 1.4 Beobachtete Schwächen
|
||||
|
||||
1. **Begriff „Versionen" missverständlich** – sind faktisch wiederverwendbare *Inhalts-Bausteine*, nicht „Versionen" im Sinne von „Revisionen".
|
||||
2. **Kein Live/Entwurfs-Trennung pro Display** – jede Änderung an einem Baustein wirkt sofort auf alle Displays, die ihn nutzen. Es gibt kein „in Arbeit, noch nicht veröffentlichen".
|
||||
3. **Kein dediziertes Test-Display** – will man eine Konfiguration vorab prüfen, muss man sie produktiv ausspielen.
|
||||
4. **Keine visuelle Vorschau im Editor** – beim Bearbeiten eines Slides sieht man nicht, wie er auf dem Display aussehen wird.
|
||||
5. **Hilfetexte nur im Dashboard** – Listen- und Editor-Seiten haben kaum Erklärungen.
|
||||
6. **Versions-Settings JSON-Wirrwarr** – Theme, Footer, Transition etc. werden je Typ unterschiedlich gepflegt, ohne sichtbares Schema.
|
||||
|
||||
---
|
||||
|
||||
## 2. Zielbild
|
||||
|
||||
> *„Pro Display gibt es einen klar veröffentlichten Stand (Live) und optional einen Entwurf, der gefahrlos vorbereitet und getestet werden kann. Veröffentlichen ist ein bewusster Schritt. Module sind wiederverwendbare Bausteine. Beim Bearbeiten sieht man sofort, wie es auf dem Display aussieht."*
|
||||
|
||||
### 2.1 Neue Begriffswelt
|
||||
|
||||
| Alt | Neu | Erklärung |
|
||||
|---|---|---|
|
||||
| Display-Version | **Modul** | Wiederverwendbarer Inhalts-Baustein eines Typs (Video, B2in, Offers). |
|
||||
| Display | **Display** *(unverändert)* | Physischer Bildschirm im Showroom. |
|
||||
| Display ↔ Version Pivot | **Bespielung** (Playlist) | Geordnete Liste von Modulen pro Display. Existiert pro Display in zwei Ausprägungen: **Live** und **Entwurf**. |
|
||||
| Mediathek | **Display-Mediathek** *(unverändert)* | Bilder/Videos für Displays. |
|
||||
| Info-Tablet | **Info-Tablet** *(unverändert)* | Eingangs-Tablet mit Öffnungszeiten. |
|
||||
|
||||
Routen werden entsprechend umbenannt: `display-versions` → `display-modules`.
|
||||
|
||||
### 2.2 Neues mentales Modell
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ MODULE (Bausteine) │
|
||||
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
|
||||
│ │ Modul A │ │ Modul B │ │ Modul C │ ... │
|
||||
│ │ Type:Video │ │ Type:B2in │ │ Type:Offers │ │
|
||||
│ └─────────────┘ └─────────────┘ └─────────────┘ │
|
||||
└────────┬─────────────────┬─────────────────┬─────────────────┘
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ DISPLAYS │
|
||||
│ ┌────────────────────┐ ┌────────────────────┐ │
|
||||
│ │ Display 1 │ │ Display 2 │ │
|
||||
│ │ ┌────────────────┐ │ │ ┌────────────────┐ │ │
|
||||
│ │ │ ▶ Live │ │ │ │ ▶ Live │ │ │
|
||||
│ │ │ Modul A→B │ │ │ │ Modul C │ │ │
|
||||
│ │ ├────────────────┤ │ │ ├────────────────┤ │ │
|
||||
│ │ │ ✎ Entwurf │ │ │ │ (kein Entwurf) │ │ │
|
||||
│ │ │ Modul A→B→C │ │ │ └────────────────┘ │ │
|
||||
│ │ └────────────────┘ │ │ │ │
|
||||
│ └────────────────────┘ └────────────────────┘ │
|
||||
│ │
|
||||
│ ┌────────────────────┐ │
|
||||
│ │ Test-Display │ ← neu, technisch ein Display │
|
||||
│ │ Beliebig kombi- │ mit Sonder-Flag is_test=true │
|
||||
│ │ nierbar zum Testen │ │
|
||||
│ └────────────────────┘ │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 2.3 Festlegungen aus Klärung
|
||||
|
||||
1. Begriff: **„Modul"** (statt Version/Baustein).
|
||||
2. Versionierung: **Pro Display-Bespielung** (Live + Entwurf), *nicht* pro Modul.
|
||||
3. Vorlagen-Konzept: **Ein eigenes Test-Display**, auf dem beliebige Module/Bespielungen ausprobiert werden können.
|
||||
4. Vorschau: **alle drei Varianten** – Inline-Thumbnail je Item, Iframe-Live-Preview neben Editor, Vollbild-Vorschau auf Knopfdruck.
|
||||
|
||||
---
|
||||
|
||||
## 3. Datenmodell – Soll-Zustand
|
||||
|
||||
### 3.1 Neue Tabellen
|
||||
|
||||
#### `display_playlists`
|
||||
Pro Display max. zwei Datensätze: eine `published` und eine `draft`.
|
||||
|
||||
```php
|
||||
Schema::create('display_playlists', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('display_id')->constrained()->cascadeOnDelete();
|
||||
$table->enum('status', ['published', 'draft']);
|
||||
$table->timestamp('published_at')->nullable();
|
||||
$table->foreignId('published_by')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->text('notes')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['display_id', 'status']);
|
||||
});
|
||||
```
|
||||
|
||||
#### `display_playlist_items`
|
||||
Ersetzt funktional `display_display_version`.
|
||||
|
||||
```php
|
||||
Schema::create('display_playlist_items', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('display_playlist_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('display_version_id')->constrained()->cascadeOnDelete();
|
||||
$table->unsignedInteger('sort_order')->default(0);
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['display_playlist_id', 'sort_order']);
|
||||
});
|
||||
```
|
||||
|
||||
### 3.2 Anpassungen bestehender Tabellen
|
||||
|
||||
#### `displays`
|
||||
- `is_test` BOOLEAN (default false) – kennzeichnet das **Test-Display**.
|
||||
- `preview_token` STRING (nullable, unique) – wird beim Erstellen einer Draft-Bespielung generiert; ermöglicht eine `cabinet.b2in.eu/preview/{token}` Adresse für Tablet-Tests ohne Login.
|
||||
|
||||
#### `display_versions` → begrifflich „Module"
|
||||
Tabelle und Modell bleiben **technisch erhalten** (keine Rename-Migration → keine Risiken bei Live-Daten). Nur die UI-Bezeichnung wechselt zu „Modul".
|
||||
|
||||
### 3.3 Daten-Migration
|
||||
|
||||
Eine einmalige Migration überführt alle aktuellen `display_display_version`-Einträge in das neue Schema:
|
||||
|
||||
```
|
||||
für jedes Display D:
|
||||
erstelle display_playlists (display_id=D.id, status='published', published_at=now())
|
||||
für jeden Eintrag aus display_display_version (display_id=D.id), sortiert nach sort_order:
|
||||
erstelle display_playlist_items (...)
|
||||
display_display_version-Tabelle bleibt vorerst → wird in Phase 7 dropped.
|
||||
```
|
||||
|
||||
**Ergebnis nach Migration:** Alle 5 Displays haben eine Live-Bespielung, kein Entwurf. Konsumenten-API liefert exakt das gleiche wie heute.
|
||||
|
||||
---
|
||||
|
||||
## 4. API – Anpassungen
|
||||
|
||||
### 4.1 Endpunkte
|
||||
|
||||
| Endpunkt | Bedeutung | Verhalten |
|
||||
|---|---|---|
|
||||
| `GET /api/display/{display}/config` | Live-Konfiguration | Liest aus `display_playlists` mit Status `published`. **Unverändert** für die im Showroom installierten Player. |
|
||||
| `GET /api/display/{display}/check` | Modifikationszeit für Polling | Bezieht sich auf die Published-Playlist. |
|
||||
| `GET /api/display/preview/{token}` *(neu)* | Vorschau-Konfiguration | Liefert die Draft-Playlist eines Displays. Token aus `displays.preview_token`. Kein Login nötig, aber Token rotierbar. |
|
||||
|
||||
### 4.2 Backward Compatibility
|
||||
|
||||
Das Antwortschema bleibt identisch (`playlist[]`, `updated_at`). Player im Showroom muss nicht angefasst werden.
|
||||
|
||||
---
|
||||
|
||||
## 5. UI / UX – Neu
|
||||
|
||||
### 5.1 Navigation
|
||||
|
||||
```
|
||||
Store Displays
|
||||
├── Übersicht (Dashboard, klarer Workflow-Erklärung)
|
||||
├── Mediathek
|
||||
├── Module ← war „Versionen"
|
||||
├── Displays ← Live + Entwurf je Display
|
||||
└── Info-Tablet
|
||||
```
|
||||
|
||||
### 5.2 Display-Liste (Hauptansicht)
|
||||
|
||||
Pro Display eine Karte mit zwei Spalten:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ ● Display 1 – Eingang Schaufenster rechts │
|
||||
│ ─────────────────────────────────────────────────────────── │
|
||||
│ ▶ LIVE ✎ ENTWURF │
|
||||
│ [Modul Schaufenster Video] (kein Entwurf) │
|
||||
│ [Modul B2In Immo Dark] │
|
||||
│ Aktualisiert: 02.05.2026 [Entwurf anlegen] │
|
||||
│ [Vorschau] [API] │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Wenn Entwurf existiert:
|
||||
|
||||
```
|
||||
▶ LIVE ✎ ENTWURF
|
||||
[Modul A] [Modul A]
|
||||
[Modul B] [Modul B]
|
||||
[Modul C] ← neu
|
||||
Aktualisiert: 02.05.2026 Test-URL: …xY12pq
|
||||
[Vorschau] [API] [Bearbeiten] [Veröffentlichen] [Verwerfen]
|
||||
```
|
||||
|
||||
### 5.3 Entwurf-Editor
|
||||
|
||||
Nur ein Modal/Sub-Page, **kein** weiteres komplexes UI:
|
||||
|
||||
- Module hinzufügen aus Bibliothek (Multi-Select)
|
||||
- Sortierung per ↑/↓
|
||||
- Sofortige Iframe-Vorschau rechts (lädt `/preview/{token}` aus aktuellem Entwurfsstand, auto-reload bei Änderung)
|
||||
- Veröffentlichen-Button mit Confirm
|
||||
- Verwerfen → löscht Draft
|
||||
|
||||
### 5.4 Test-Display
|
||||
|
||||
Eigene Top-Level-Kachel **„Test-Display"** im Display-Dashboard. Technisch ein normaler `Display`-Datensatz mit `is_test=true`. Eigene URL `/_cabinet/display/?id=TEST_ID` für lokales Vorab-Anschauen.
|
||||
|
||||
In der Liste ist es deutlich abgesetzt (anderes Icon, gelbe Border), damit es nicht versehentlich als Produktion-Display verwendet wird.
|
||||
|
||||
### 5.5 Modul-Editor – drei Vorschau-Stufen
|
||||
|
||||
#### Stufe A: Inline-Mini-Preview je Item
|
||||
In den Listen `version-editor-{video|b2in|offers}.blade.php`: pro Item-Zeile ein Thumbnail/Mini-Layout neben Headline und Subline. Konkret:
|
||||
|
||||
- **Video-Item:** Video-Thumbnail (erstes Frame oder Standbild) + Filename-Badge
|
||||
- **Footer-Item:** kleines Footer-Layout mit Headline+Subline+QR-Stub
|
||||
- **Media-Item:** Bild-Thumbnail + Headline-Overlay-Skizze
|
||||
- **Slide-Item:** Mini-Skizze des Slide-Layouts (Hero/Details/Impulse erkennbar)
|
||||
|
||||
#### Stufe B: Iframe-Live-Preview neben Editor
|
||||
Beim Bearbeiten eines Moduls splittet sich die Ansicht:
|
||||
|
||||
```
|
||||
┌────────────────────────┬──────────────────────────┐
|
||||
│ Items / Form │ 9:16 Iframe │
|
||||
│ (links, scrollbar) │ Live-Preview aus │
|
||||
│ │ /preview/module/{id} │
|
||||
└────────────────────────┴──────────────────────────┘
|
||||
```
|
||||
|
||||
Die Iframe-URL lädt das Modul isoliert (ohne Display-Bespielung) – dafür legen wir an: `GET /preview/module/{module}` (gerendert via vorhandene Display-Player-Templates, aber im Single-Module-Modus).
|
||||
|
||||
#### Stufe C: Vollbild-Vorschau auf Knopfdruck
|
||||
Button „Vollbild-Vorschau" → öffnet `/preview/module/{module}` in neuem Tab in voller 9:16-Größe.
|
||||
|
||||
### 5.6 Dashboard
|
||||
|
||||
Bleibt strukturell wie heute, aber mit:
|
||||
- aktualisierten Bezeichnungen
|
||||
- zusätzlicher Zeile pro Display: Live/Entwurf-Stand
|
||||
- prominentem „Test-Display"-Eintrag
|
||||
- ausgebautem Workflow-Block (1. Modul anlegen → 2. Display-Entwurf bauen → 3. Testen → 4. Veröffentlichen)
|
||||
|
||||
---
|
||||
|
||||
## 6. Authentifizierung / Berechtigungen
|
||||
|
||||
- Alle Admin-Routen weiterhin `auth + partner.setup`.
|
||||
- Vorschau-Routen `/preview/{token}` und `/preview/module/{id}` sind **öffentlich, aber per Token bzw. nur auf nicht-aktiven Daten** zugreifbar. Token rotierbar per Button im Display-Detail.
|
||||
|
||||
---
|
||||
|
||||
## 7. Tests (Pflicht laut Projekt-Regeln)
|
||||
|
||||
| Test | Datei |
|
||||
|---|---|
|
||||
| Bestehende Versions-API funktioniert weiter | `tests/Feature/DisplayVersionApiTest.php` (anpassen) |
|
||||
| Migration `display_display_version` → `display_playlists` ist verlustfrei | `tests/Feature/DisplayPlaylistMigrationTest.php` (neu) |
|
||||
| Neuer Workflow: Entwurf anlegen → bearbeiten → veröffentlichen | `tests/Feature/DisplayPublishWorkflowTest.php` (neu) |
|
||||
| Vorschau via Token ohne Login erreichbar | `tests/Feature/DisplayPreviewTokenTest.php` (neu) |
|
||||
| Test-Display verhält sich korrekt | `tests/Feature/DisplayTestFlagTest.php` (neu) |
|
||||
| Modul-Preview-Endpoint funktioniert | `tests/Feature/ModulePreviewTest.php` (neu) |
|
||||
|
||||
Bestehende Tests aus `DisplayListTest`, `DisplayVersionTest`, `DisplayVersionApiTest` werden angepasst – nicht ersetzt.
|
||||
|
||||
---
|
||||
|
||||
## 8. Phasen-Roadmap
|
||||
|
||||
Jede Phase liefert ein in sich getestetes, deploybares Inkrement.
|
||||
|
||||
### Phase 1 – Datenmodell (Tag 1)
|
||||
- [ ] Migration `display_playlists` + `display_playlist_items`
|
||||
- [ ] Migration: `displays.is_test`, `displays.preview_token`
|
||||
- [ ] Daten-Migration aus `display_display_version`
|
||||
- [ ] Modelle `DisplayPlaylist`, `DisplayPlaylistItem`
|
||||
- [ ] Relations & Factories
|
||||
- [ ] Test: Daten-Migration verlustfrei
|
||||
|
||||
### Phase 2 – API & Player (Tag 1)
|
||||
- [ ] `DisplayConfigController` liest aus Published-Playlist
|
||||
- [ ] Neuer `DisplayPreviewController` für Token-Preview
|
||||
- [ ] Neuer `ModulePreviewController` für Modul-Einzelvorschau
|
||||
- [ ] Tests aktualisiert + neu
|
||||
|
||||
### Phase 3 – Admin-UI: Displays-Liste (Tag 2)
|
||||
- [ ] Liste mit Live + Entwurf je Display
|
||||
- [ ] Aktionen: Entwurf anlegen / verwerfen / veröffentlichen
|
||||
- [ ] Test-Display als eigene, hervorgehobene Karte
|
||||
- [ ] Vorschau-Buttons (Live + Entwurf)
|
||||
|
||||
### Phase 4 – Admin-UI: Entwurf-Editor (Tag 2)
|
||||
- [ ] Modal/Page zum Bearbeiten der Entwurf-Playlist (Module hinzufügen, sortieren, entfernen)
|
||||
- [ ] Iframe-Vorschau rechts
|
||||
- [ ] Auto-Reload der Iframe bei Änderung
|
||||
|
||||
### Phase 5 – Modul-Editor Vorschau-Ausbau (Tag 3)
|
||||
- [ ] Stufe A: Inline-Mini-Preview je Item-Typ (Video/Footer/Media/Slide)
|
||||
- [ ] Stufe B: Iframe-Live-Preview rechts neben Modul-Editor
|
||||
- [ ] Stufe C: Vollbild-Vorschau-Button
|
||||
- [ ] Player-Templates: Single-Module-Modus
|
||||
|
||||
### Phase 6 – Umbenennung & Onboarding (Tag 3)
|
||||
- [ ] Routen: `display-versions` → `display-modules` (mit 301-Redirect)
|
||||
- [ ] Komponenten / Views umbenennen
|
||||
- [ ] Dashboard-Texte / Hilfe-Bausteine aktualisieren
|
||||
- [ ] Tooltips an Schlüsselstellen
|
||||
|
||||
### Phase 7 – Aufräumen (Tag 4)
|
||||
- [ ] `display_display_version`-Tabelle dropped
|
||||
- [ ] Alte Routen entfernt
|
||||
- [ ] `DISPLAY_CMS_README.md` aktualisiert (in `dev/` ablegen)
|
||||
- [ ] Vollständiger Test-Run
|
||||
|
||||
---
|
||||
|
||||
## 9. Risiken & Migrationsschutz
|
||||
|
||||
| Risiko | Maßnahme |
|
||||
|---|---|
|
||||
| Live-Displays brechen | API-Schema unverändert; Daten-Migration zuerst, Live-Tests vorher |
|
||||
| Konflikte bei parallelen Edits | Optimistic Lock per `updated_at` auf Playlist (vorerst nicht zwingend) |
|
||||
| Token-Preview öffentlich | Token mind. 24 Zeichen, rotierbar, nur Draft sichtbar |
|
||||
| Verwirrung „Modul" vs „Version" | 301 von alten Routen, Hinweis im Dashboard zur Umbenennung |
|
||||
|
||||
---
|
||||
|
||||
## 10. Offene Fragen / Klärungsbedarf vor Phase 1
|
||||
|
||||
Bevor wir Phase 1 starten, sind diese Punkte zu bestätigen:
|
||||
|
||||
1. **„Test-Display"** – soll es **fest „1"** geben (Singleton) oder beliebig viele? *(Empfehlung: genau 1 angelegt durch Seeder, weitere optional erstellbar.)*
|
||||
2. **Verwerfen vs. „auf Live zurücksetzen"** – beim Verwerfen eines Entwurfs: einfach löschen, oder soll der Entwurf auf den Stand der Live-Bespielung zurückgesetzt werden? *(Empfehlung: löschen, beim Neuanlegen wird der Stand aus Live geklont.)*
|
||||
3. **Modul gleichzeitig in Live und Entwurf eines Displays** – darf vorkommen (z.B. Reihenfolge ändern). Module bleiben dabei *shared* zwischen Displays. Wirken Modul-Änderungen sofort auch auf Live-Displays? **Ja** (technisch das gleiche Datum). Falls das nicht gewünscht ist, müssten Module ebenfalls versioniert werden – das ist Out-Of-Scope für diesen Konzeptstand.
|
||||
4. **Player-Cache** – nach Veröffentlichen soll der Polling-Mechanismus (60 s) ausreichen, oder brauchen wir einen Push-Refresh? *(Empfehlung: 60 s reicht.)*
|
||||
|
||||
---
|
||||
|
||||
## 11. Was passiert mit dem alten Pfad `cms/cabinet`?
|
||||
|
||||
Die ursprüngliche `CabinetDisplay`-Komponente (mit `DisplayVideo` + `DisplayFooterContent`) ist **Legacy** und gehört zur alten 1-Display-Welt unter `/_cabinet/index.html`. Sie wird im Konzeptzeitraum nicht angefasst, aber im Anschluss empfohlen zu entfernen, sobald alle Live-Displays auf `/_cabinet/display/?id=…` umgeschaltet sind.
|
||||
|
||||
---
|
||||
|
||||
## 12. Nächster Schritt
|
||||
|
||||
→ Das Konzept wurde am 11.05.2026 freigegeben und die Umsetzung wurde gestartet.
|
||||
→ Der laufende Fortschritt wird in `01-status.md` gepflegt.
|
||||
278
dev/displays-11-05-2026/01-status.md
Normal file
278
dev/displays-11-05-2026/01-status.md
Normal file
|
|
@ -0,0 +1,278 @@
|
|||
# Cabinet Displays – Implementierungs-Status
|
||||
|
||||
> Konzept: siehe `00-entwicklungskonzept.md`. Diese Datei wird je Phase fortgeschrieben.
|
||||
|
||||
## Übersicht
|
||||
|
||||
| Phase | Inhalt | Status |
|
||||
|---|---|---|
|
||||
| **0** | Konzept-Freigabe | ✅ 11.05.2026 |
|
||||
| **1** | Datenmodell + Daten-Migration | ✅ 11.05.2026 |
|
||||
| **2** | API & Player (config + preview + module preview) | ✅ 12.05.2026 |
|
||||
| **3** | Admin-UI: Displays-Liste mit Live/Entwurf | ✅ 12.05.2026 |
|
||||
| **4** | Admin-UI: Entwurf-Editor (Iframe-Vorschau) | ✅ 12.05.2026 |
|
||||
| **5** | Modul-Editor: 3-stufige Vorschau | ✅ 12.05.2026 |
|
||||
| **6** | Umbenennung Versionen → Module + Onboarding | ✅ 12.05.2026 |
|
||||
| **7** | Aufräumen + alte Pivot-Tabelle entfernen | ⏳ offen |
|
||||
|
||||
Legende: ✅ fertig · 🟡 in Arbeit · ⏳ offen · ⛔ blockiert
|
||||
|
||||
---
|
||||
|
||||
## Defaults aus §10 (Konzept-Freigabe vom 11.05.2026)
|
||||
|
||||
Der User hat das Konzept freigegeben. Da keine abweichende Wahl getroffen wurde, gelten die im Konzept empfohlenen Defaults:
|
||||
|
||||
1. **Test-Display:** genau 1 Datensatz per Seeder angelegt, weitere können bei Bedarf erstellt werden.
|
||||
2. **Entwurf verwerfen:** löscht die Draft-Playlist. Beim erneuten Anlegen wird die Reihenfolge aus Live kopiert.
|
||||
3. **Module bleiben *shared*:** Modul-Änderungen wirken sofort auf alle Displays, die das Modul live einsetzen. Modul-eigene Versionierung ist *out of scope*.
|
||||
4. **Polling-Mechanismus** der Player (alle 60 s) reicht; kein Push-Refresh.
|
||||
|
||||
---
|
||||
|
||||
## Live-Roll-out Hinweise
|
||||
|
||||
Auf dem Live-Server reicht für jede Phase:
|
||||
|
||||
```bash
|
||||
git pull
|
||||
composer install --no-dev --optimize-autoloader # falls neue Composer-Deps
|
||||
php artisan migrate --force # bringt neue Migrations ein
|
||||
php artisan config:cache # Caches frisch
|
||||
php artisan view:clear
|
||||
```
|
||||
|
||||
Alle strukturellen Änderungen liegen **ausschließlich** in `database/migrations/` als datierte Dateien vor – auf der Live-DB reicht `php artisan migrate --force`. Datenverlust gibt es nicht: bestehende Pivot-Daten aus `display_display_version` werden in die neue Struktur überführt.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 – Datenmodell
|
||||
|
||||
**Ziel:** neue Tabellen `display_playlists` + `display_playlist_items`, erweiterte `displays`-Spalten (`is_test`, `preview_token`), vollständige verlustfreie Migration der heutigen Pivot-Daten.
|
||||
|
||||
**Dateien:**
|
||||
- `database/migrations/2026_05_11_*_create_display_playlists_table.php`
|
||||
- `database/migrations/2026_05_11_*_create_display_playlist_items_table.php`
|
||||
- `database/migrations/2026_05_11_*_add_test_flag_and_preview_token_to_displays_table.php`
|
||||
- `database/migrations/2026_05_11_*_migrate_pivot_to_display_playlists.php`
|
||||
- `app/Models/DisplayPlaylist.php`
|
||||
- `app/Models/DisplayPlaylistItem.php`
|
||||
- `app/Models/Display.php` (neue Relations, alte `versions()` bleibt kompatibel)
|
||||
- `database/factories/DisplayPlaylistFactory.php`
|
||||
- `database/factories/DisplayPlaylistItemFactory.php`
|
||||
- `tests/Feature/DisplayPlaylistMigrationTest.php`
|
||||
|
||||
**Wichtig:** Die alte Tabelle `display_display_version` und die Relation `Display::versions()` bleiben in dieser Phase **erhalten** (Removal erst in Phase 7), damit keine bestehenden Funktionen brechen.
|
||||
|
||||
### Stand 11.05.2026 – ✅ abgeschlossen
|
||||
|
||||
#### Gelieferte Migrationen (Live-Reihenfolge)
|
||||
|
||||
| Reihenfolge | Datei | Zweck |
|
||||
|---|---|---|
|
||||
| 1 | `2026_05_11_113300_add_test_flag_and_preview_token_to_displays_table.php` | `displays.is_test` + `displays.preview_token` (unique) |
|
||||
| 2 | `2026_05_11_113310_create_display_playlists_table.php` | Neue Tabelle für Live/Entwurfs-Bespielung pro Display |
|
||||
| 3 | `2026_05_11_113320_create_display_playlist_items_table.php` | Geordnete Module pro Bespielung |
|
||||
| 4 | `2026_05_11_113330_migrate_pivot_to_display_playlists.php` | Übernimmt bestehende Pivot-Daten als Published-Playlists (idempotent) |
|
||||
|
||||
Die Daten-Migration ist **idempotent** – wenn pro Display bereits eine Published-Playlist existiert, wird sie übersprungen. Die alte Pivot-Tabelle `display_display_version` bleibt erhalten.
|
||||
|
||||
#### Neue Modelle & Relations
|
||||
|
||||
- `App\Models\DisplayPlaylist` mit Scopes `published()`/`draft()`, Relation `modules()` (geordnet)
|
||||
- `App\Models\DisplayPlaylistItem`
|
||||
- `App\Models\Display` erweitert um `playlists()`, `livePlaylist()`, `draftPlaylist()`, `liveModules()`, `ensurePreviewToken()`
|
||||
- `Display::versions()` ist als `@deprecated` markiert, bleibt aber funktional
|
||||
|
||||
#### Tests
|
||||
|
||||
```text
|
||||
tests/Feature/DisplayPlaylistMigrationTest.php – 11 passed
|
||||
tests/Feature/DisplayListTest.php – ok
|
||||
tests/Feature/DisplayVersionTest.php – ok
|
||||
tests/Feature/DisplayVersionApiTest.php – ok
|
||||
tests/Feature/DisplayMediaTest.php – ok
|
||||
```
|
||||
|
||||
Insgesamt 67 grüne Tests rund um Displays (11 neu + 56 bestand). Keine Linter-Warnungen, Pint clean.
|
||||
|
||||
#### Roll-out auf Live
|
||||
|
||||
```bash
|
||||
git pull
|
||||
php artisan migrate --force
|
||||
```
|
||||
|
||||
Reihenfolge stimmt durch die Timestamps automatisch. Daten-Migration ist idempotent, kann beliebig oft laufen.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 – API & Player
|
||||
|
||||
**Ziel:** bestehende Player-Konfiguration liest künftig aus der Published-Playlist. Zusätzlich gibt es öffentliche Vorschau-Endpunkte für Display-Entwürfe per Token und einzelne Module. Das JSON-Schema bleibt rückwärtskompatibel (`playlist[]`, `updated_at`).
|
||||
|
||||
### Stand 12.05.2026 – ✅ abgeschlossen
|
||||
|
||||
Geplante/aktuelle Dateien:
|
||||
- `app/Services/DisplayPlaylistConfigBuilder.php`
|
||||
- `app/Http/Controllers/Api/DisplayVersionApiController.php`
|
||||
- `app/Http/Controllers/Api/DisplayPreviewController.php`
|
||||
- `app/Http/Controllers/Api/ModulePreviewController.php`
|
||||
- `routes/domains.php`
|
||||
- `public/_cabinet/display/index.html`
|
||||
- `tests/Feature/DisplayVersionApiTest.php`
|
||||
|
||||
Umsetzung:
|
||||
- Live-Config: `GET /api/display/{display}/config` liest `livePlaylist`
|
||||
- Live-Check: `GET /api/display/{display}/check` bezieht sich auf die Published-Playlist
|
||||
- Draft-Preview: `GET /api/display/preview/{token}` liefert die Draft-Playlist
|
||||
- Modul-Preview: `GET /api/display/module/{module}/preview` liefert ein Einzelmodul im Player-Schema
|
||||
- Player-Preview-Seiten: `/preview/{token}` und `/preview/module/{module}`
|
||||
|
||||
#### Tests
|
||||
|
||||
```text
|
||||
tests/Feature/DisplayVersionApiTest.php – 13 passed
|
||||
tests/Feature/DisplayPlaylistMigrationTest.php – ok
|
||||
```
|
||||
|
||||
Insgesamt 24 grüne Tests für Phase 2 und die Playlist-Grundlage. Pint clean.
|
||||
|
||||
#### Hinweis
|
||||
|
||||
In der lokalen Umgebung musste der alte Route-Cache einmal mit `php artisan route:clear` geleert werden, damit die neuen Preview-Routen sichtbar wurden. Für Live bleibt beim Roll-out `php artisan config:cache` bzw. ein frischer Route-/Config-Cache relevant.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 – Admin-UI: Displays-Liste mit Live/Entwurf
|
||||
|
||||
**Ziel:** Die Display-Liste zeigt pro physischem Display den Live-Stand und optionalen Entwurf nebeneinander. Entwürfe können aus Live angelegt, verworfen und veröffentlicht werden. Das Test-Display ist sichtbar hervorgehoben.
|
||||
|
||||
### Stand 12.05.2026 – ✅ abgeschlossen
|
||||
|
||||
Dateien:
|
||||
- `app/Livewire/Admin/Cms/DisplayList.php`
|
||||
- `resources/views/livewire/admin/cms/display-list.blade.php`
|
||||
- `tests/Feature/DisplayListTest.php`
|
||||
|
||||
Umsetzung:
|
||||
- Display-Karten zeigen `Live` und `Entwurf` als zwei getrennte Spalten
|
||||
- Der globale Bearbeiten-Button im Display-Kopf wurde entfernt; Live und Entwurf haben jeweils eigene Bearbeiten-Buttons
|
||||
- Die Live-Card zeigt die Player-URL direkt als kopierbares Feld; der API-Link ist weniger prominent unten rechts platziert
|
||||
- Live-Spalte nutzt `livePlaylist.modules`
|
||||
- Entwurf-Spalte nutzt `draftPlaylist.modules`
|
||||
- Live-Bearbeitung speichert nur die Published-Playlist
|
||||
- Entwurfs-Bearbeitung speichert nur die Draft-Playlist und erzeugt bei Bedarf den Preview-Token
|
||||
- Die Modul-Auswahl im Bearbeiten-Dialog zeigt nur noch Module, die in der aktuell bearbeiteten Bespielung noch nicht enthalten sind
|
||||
- Der Plus-Button fügt auch dann das erste verfügbare Modul hinzu, wenn der Select-Wert wegen des Platzhalters noch nicht explizit gesetzt wurde
|
||||
- Aktion `Entwurf anlegen` kopiert den aktuellen Live-Stand und erzeugt bei Bedarf den Preview-Token
|
||||
- Aktion `Veröffentlichen` ersetzt die Published-Playlist durch den Draft und synchronisiert die alte Pivot-Tabelle weiterhin kompatibel
|
||||
- Aktion `Verwerfen` löscht die Draft-Playlist
|
||||
- Test-Displays (`is_test`) werden in der Liste hervorgehoben
|
||||
- Der bestehende Bearbeiten-Dialog pflegt die Live-Bespielung weiter und synchronisiert bis Phase 7 zusätzlich `display_display_version`
|
||||
|
||||
#### Tests
|
||||
|
||||
```text
|
||||
tests/Feature/DisplayListTest.php – 14 passed
|
||||
```
|
||||
|
||||
Die neuen Tests decken Draft anlegen, verwerfen, veröffentlichen, getrennte Live-/Entwurfs-Bearbeitung, gefilterte Modul-Auswahl sowie die Darstellung von Live-/Entwurf-Modulen ab.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 – Admin-UI: Entwurf-Editor mit Iframe-Vorschau
|
||||
|
||||
**Ziel:** Beim Bearbeiten eines Entwurfs ist die Player-Vorschau direkt sichtbar. Moduländerungen im Entwurf werden sofort in die Draft-Playlist geschrieben und laden die Vorschau neu.
|
||||
|
||||
### Stand 12.05.2026 – ✅ abgeschlossen
|
||||
|
||||
Dateien:
|
||||
- `app/Livewire/Admin/Cms/DisplayList.php`
|
||||
- `resources/views/livewire/admin/cms/display-list.blade.php`
|
||||
- `tests/Feature/DisplayListTest.php`
|
||||
|
||||
Umsetzung:
|
||||
- Der Entwurfs-Editor zeigt rechts eine 9:16-Iframe-Vorschau
|
||||
- Die Iframe-Vorschau sitzt dauerhaft unterhalb der Aktualisieren-Aktion, damit sie im Desktop-Modal nicht mit dem Formular überlappt
|
||||
- Die Vorschau lädt `/preview/{token}` mit Cache-Bust-Parameter
|
||||
- Hinzufügen, Entfernen und Sortieren von Modulen persistiert bei Draft-Bearbeitung sofort in `display_playlist_items`
|
||||
- Nach jeder Moduländerung wird die Iframe-Vorschau per `wire:key` neu aufgebaut
|
||||
- Vollbild-Vorschau ist aus dem Editor heraus verlinkt
|
||||
|
||||
#### Tests
|
||||
|
||||
```text
|
||||
tests/Feature/DisplayListTest.php – 21 passed
|
||||
```
|
||||
|
||||
Die Tests decken das Rendern der Vorschau-URL und das sofortige Persistieren von Draft-Änderungen für Preview-Reloads ab.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5 – Modul-Editor: 3-stufige Vorschau
|
||||
|
||||
**Ziel:** Module können beim Bearbeiten visuell geprüft werden: kleine Vorschau je Item, eingebettete Player-Vorschau und Vollbild-Vorschau.
|
||||
|
||||
### Stand 12.05.2026 – ✅ abgeschlossen
|
||||
|
||||
Dateien:
|
||||
- `app/Livewire/Admin/Cms/DisplayVersionEditor.php`
|
||||
- `resources/views/livewire/admin/cms/display-version-editor.blade.php`
|
||||
- `resources/views/livewire/admin/cms/partials/version-editor-video.blade.php`
|
||||
- `resources/views/livewire/admin/cms/partials/version-editor-b2in.blade.php`
|
||||
- `resources/views/livewire/admin/cms/partials/version-editor-offers.blade.php`
|
||||
- `tests/Feature/DisplayVersionTest.php`
|
||||
|
||||
Umsetzung:
|
||||
- Modul-Editor zeigt eine 9:16-Iframe-Vorschau über `/preview/module/{module}`
|
||||
- Vollbild-Vorschau ist direkt aus dem Editor verlinkt
|
||||
- Das Item-Bearbeiten-Modal ist breiter und zeigt unterhalb der Aktualisieren-Aktion ebenfalls eine 9:16-Iframe-Vorschau
|
||||
- `Aktualisieren` im Item-Bearbeiten-Modal schließt das Modal nicht mehr, sondern speichert den Inhalt und lädt die Iframe-Vorschau neu
|
||||
- Die Modal-Aktionen stehen unterhalb der Iframe-Vorschau und bieten `Aktualisieren`, `Abbrechen` und `Schließen`
|
||||
- Die Vorschau im Item-Bearbeiten-Modal nutzt eine eigene Item-Preview und zeigt nur den aktuell bearbeiteten Slide statt das komplette Modul
|
||||
- Der Display-Player rendert seinen Viewport strikt als 9:16-Fläche und skaliert Slide-, Footer- und QR-Elemente proportional zur Player-Fläche
|
||||
- Harte Player-Elemente wie B2in-Headerlogo/Claim/Footer/QR, Offers-Logo/Brandtext/QR-Labels und Video-Footer-QR-Label sind jetzt als Modul-Einstellungen im CMS pflegbar
|
||||
- Die Modul-Meta-Einstellungen sind als sichtbarer Block unterhalb der Media-/Slide-Liste editierbar; Angebote vererben Footer-Claim und Web/QR-URL automatisch an alle Slides
|
||||
- Logo-Alt-Text-Felder wurden aus den Modul-Meta-Einstellungen entfernt; Player nutzen weiterhin feste Fallback-Alt-Texte
|
||||
- Video-Display unterstützt Mediathek-Upload-URLs wie `/storage/...` ohne Legacy-`assets/`-Prefix
|
||||
- Die Display-Mediathek und der schnelle Media-Picker akzeptieren SVG-Dateien als Bild-Uploads
|
||||
- Änderungen an Name, Einstellungen, Items, Reihenfolge und Aktiv-Status laden die Modul-Vorschau neu
|
||||
- Video/Footer/Media/Slide-Listen zeigen Inline-Mini-Previews je Item; Slide-Previews sind größer dargestellt
|
||||
- Einzelmodul-Vorschau nutzt weiterhin den Player aus Phase 2
|
||||
|
||||
---
|
||||
|
||||
## Phase 6 – Umbenennung Versionen → Module + Onboarding
|
||||
|
||||
**Ziel:** Die Admin-UI verwendet den fachlich korrekten Begriff „Module“. Alte URLs bleiben kompatibel und leiten weiter.
|
||||
|
||||
### Stand 12.05.2026 – ✅ abgeschlossen
|
||||
|
||||
Dateien:
|
||||
- `routes/admin.php`
|
||||
- `app/Livewire/Admin/Cms/DisplayVersionList.php`
|
||||
- `resources/views/livewire/admin/cms/display-version-list.blade.php`
|
||||
- `resources/views/livewire/admin/cms/display-version-editor.blade.php`
|
||||
- `resources/views/livewire/admin/cms/display-dashboard.blade.php`
|
||||
- `resources/views/components/layouts/app/sidebar.blade.php`
|
||||
- `tests/Feature/DisplayVersionTest.php`
|
||||
|
||||
Umsetzung:
|
||||
- Neue Routen: `admin/cms/display-modules` und `admin/cms/display-modules/{displayVersion}/edit`
|
||||
- Neue Routennamen: `admin.cms.display-modules` und `admin.cms.display-module-edit`
|
||||
- Alte `display-versions`-Routen bleiben erhalten und leiten per 301 auf die Modul-Routen weiter
|
||||
- Sidebar, Dashboard, Listen- und Editor-Texte verwenden „Module“
|
||||
- Technische Modell-/Klassennamen bleiben bis Phase 7 kompatibel bei `DisplayVersion`
|
||||
|
||||
#### Tests
|
||||
|
||||
```text
|
||||
tests/Feature/DisplayVersionTest.php – ok
|
||||
tests/Feature/DisplayVersionApiTest.php – ok
|
||||
tests/Feature/DisplayListTest.php – ok
|
||||
tests/Feature/DisplayPlaylistMigrationTest.php – ok
|
||||
```
|
||||
|
||||
Insgesamt 64 grüne Tests für Phasen 5/6 und die angrenzenden Display-Flows. Pint clean.
|
||||
|
||||
16
dev/displays-11-05-2026/README.md
Normal file
16
dev/displays-11-05-2026/README.md
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
# Cabinet Displays – Re-Konzeption 11.05.2026
|
||||
|
||||
Dieser Ordner enthält das laufende Entwicklungskonzept und den Status für die Überarbeitung des `Cabinet Displays`-Bereichs im Admin-Portal.
|
||||
|
||||
## Dokumente
|
||||
|
||||
| Datei | Inhalt |
|
||||
|---|---|
|
||||
| `00-entwicklungskonzept.md` | Freigegebenes Konzept (Ist-Analyse, Zielbild, Datenmodell, Phasen-Plan) |
|
||||
| `01-status.md` | Fortschritt je Phase |
|
||||
|
||||
## Aktueller Status
|
||||
|
||||
**11.05.2026** – Konzept freigegeben und Umsetzung gestartet. Phase 1 (Datenmodell + Migration) wurde abgeschlossen.
|
||||
|
||||
**12.05.2026** – Weiterarbeit mit Phase 2 (API & Player).
|
||||
|
|
@ -40,25 +40,21 @@
|
|||
PLAYER FRAME – 9:16 Container
|
||||
======================================== */
|
||||
.player-frame {
|
||||
width: 100vw; height: 100vh;
|
||||
position: fixed; inset: 0;
|
||||
width: 100%; height: 100%;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.player-viewport {
|
||||
width: 100%; height: 100%;
|
||||
width: min(100vw, calc(100vh * 9 / 16));
|
||||
height: min(100vh, calc(100vw * 16 / 9));
|
||||
max-width: 1080px; max-height: 1920px;
|
||||
aspect-ratio: 9 / 16;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
@media (min-aspect-ratio: 9/16) {
|
||||
.player-viewport { width: auto; height: 100vh; }
|
||||
}
|
||||
@media (max-aspect-ratio: 9/16) {
|
||||
.player-viewport { width: 100vw; height: auto; }
|
||||
container-type: size;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
|
|
@ -97,11 +93,11 @@
|
|||
display: block;
|
||||
}
|
||||
.vd-footer {
|
||||
height: 9.67vh; min-height: 100px;
|
||||
height: 9.67%;
|
||||
background: #1a1a1a; color: #fff;
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 0 20px;
|
||||
font-size: 10px;
|
||||
padding: 0 3%;
|
||||
font-size: clamp(5px, 0.92cqw, 10px);
|
||||
position: relative;
|
||||
}
|
||||
.vd-footer.hidden { display: none; }
|
||||
|
|
@ -119,7 +115,7 @@
|
|||
width: 25%; display: flex; flex-direction: column; align-items: center;
|
||||
}
|
||||
.vd-qr img {
|
||||
width: 8em; max-width: 100px; aspect-ratio: 1;
|
||||
width: clamp(40px, 8.5cqw, 100px); aspect-ratio: 1;
|
||||
object-fit: contain; background: #fff;
|
||||
padding: 0.4em; border-radius: 0.6em;
|
||||
}
|
||||
|
|
@ -187,7 +183,7 @@
|
|||
.b2in-footer-url { font-size: 1.5vh; font-weight: 600; color: #20a0da; }
|
||||
.b2in-footer-name { font-size: 1.2vh; color: rgba(255,255,255,0.5); margin-left: 1vh; }
|
||||
.b2in-footer-qr img {
|
||||
height: 5vh; aspect-ratio: 1; border-radius: 0.5vh;
|
||||
height: clamp(32px, 5cqh, 96px); aspect-ratio: 1; border-radius: 0.5vh;
|
||||
background: #fff; padding: 0.3vh;
|
||||
}
|
||||
|
||||
|
|
@ -439,6 +435,18 @@
|
|||
</div>
|
||||
|
||||
<script>
|
||||
function escapeHtml(value) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = value ?? '';
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function normalizeQrUrl(url) {
|
||||
if (!url) return '';
|
||||
if (url.startsWith('http://') || url.startsWith('https://')) return url;
|
||||
return `https://${url}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cabinet Display Player
|
||||
*
|
||||
|
|
@ -449,17 +457,20 @@
|
|||
*/
|
||||
class DisplayPlayer {
|
||||
constructor() {
|
||||
// Detect display ID from URL
|
||||
// Detect display context from URL
|
||||
this.previewToken = this.detectPreviewToken();
|
||||
this.moduleId = this.detectModuleId();
|
||||
this.itemId = this.detectItemId();
|
||||
this.displayId = this.detectDisplayId();
|
||||
if (!this.displayId) {
|
||||
this.showError('Keine Display-ID angegeben. URL: /display/index.html?id=1');
|
||||
if (!this.displayId && !this.previewToken && !this.moduleId) {
|
||||
this.showError('Keine Display-ID oder Vorschau angegeben. URL: /display/index.html?id=1');
|
||||
return;
|
||||
}
|
||||
|
||||
// API
|
||||
this.BASE_URL = this.detectBaseUrl();
|
||||
this.API_CONFIG = `${this.BASE_URL}/api/display/${this.displayId}/config`;
|
||||
this.API_CHECK = `${this.BASE_URL}/api/display/${this.displayId}/check`;
|
||||
this.API_CONFIG = this.detectConfigUrl();
|
||||
this.API_CHECK = this.detectCheckUrl();
|
||||
|
||||
// Timing
|
||||
this.POLL_INTERVAL = 60000;
|
||||
|
|
@ -487,7 +498,7 @@ class DisplayPlayer {
|
|||
this.errorOverlay = document.getElementById('error-overlay');
|
||||
this.errorMessage = document.getElementById('error-message');
|
||||
|
||||
this.loadingInfo.textContent = `Display #${this.displayId}`;
|
||||
this.loadingInfo.textContent = this.detectLoadingLabel();
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
|
@ -506,6 +517,75 @@ class DisplayPlayer {
|
|||
return null;
|
||||
}
|
||||
|
||||
detectPreviewToken() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (params.get('preview')) {
|
||||
return params.get('preview');
|
||||
}
|
||||
const pathMatch = window.location.pathname.match(/\/preview\/([^/]+)/);
|
||||
if (pathMatch && pathMatch[1] !== 'module') {
|
||||
return pathMatch[1];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
detectModuleId() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (params.get('module')) {
|
||||
return params.get('module');
|
||||
}
|
||||
const pathMatch = window.location.pathname.match(/\/preview\/module\/(\d+)/);
|
||||
if (pathMatch) {
|
||||
return pathMatch[1];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
detectItemId() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (params.get('item')) {
|
||||
return params.get('item');
|
||||
}
|
||||
const pathMatch = window.location.pathname.match(/\/preview\/module\/\d+\/item\/(\d+)/);
|
||||
if (pathMatch) {
|
||||
return pathMatch[1];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
detectConfigUrl() {
|
||||
if (this.previewToken) {
|
||||
return `${this.BASE_URL}/api/display/preview/${this.previewToken}`;
|
||||
}
|
||||
if (this.moduleId && this.itemId) {
|
||||
return `${this.BASE_URL}/api/display/module/${this.moduleId}/item/${this.itemId}/preview`;
|
||||
}
|
||||
if (this.moduleId) {
|
||||
return `${this.BASE_URL}/api/display/module/${this.moduleId}/preview`;
|
||||
}
|
||||
return `${this.BASE_URL}/api/display/${this.displayId}/config`;
|
||||
}
|
||||
|
||||
detectCheckUrl() {
|
||||
if (this.previewToken || this.moduleId) {
|
||||
return null;
|
||||
}
|
||||
return `${this.BASE_URL}/api/display/${this.displayId}/check`;
|
||||
}
|
||||
|
||||
detectLoadingLabel() {
|
||||
if (this.previewToken) {
|
||||
return 'Display-Entwurf';
|
||||
}
|
||||
if (this.moduleId) {
|
||||
if (this.itemId) {
|
||||
return `Modul #${this.moduleId} / Item #${this.itemId}`;
|
||||
}
|
||||
return `Modul #${this.moduleId}`;
|
||||
}
|
||||
return `Display #${this.displayId}`;
|
||||
}
|
||||
|
||||
detectBaseUrl() {
|
||||
const hostname = window.location.hostname;
|
||||
if (hostname === 'cabinet.b2in.eu' || hostname.includes('b2in.eu')) {
|
||||
|
|
@ -519,7 +599,7 @@ class DisplayPlayer {
|
|||
// ========================================
|
||||
|
||||
async init() {
|
||||
console.log(`[Display] Initializing display #${this.displayId}`);
|
||||
console.log(`[Display] Initializing ${this.detectLoadingLabel()}`);
|
||||
|
||||
try {
|
||||
await this.fetchConfig();
|
||||
|
|
@ -563,6 +643,10 @@ class DisplayPlayer {
|
|||
}
|
||||
|
||||
startPolling() {
|
||||
if (!this.API_CHECK) {
|
||||
return;
|
||||
}
|
||||
|
||||
setInterval(async () => {
|
||||
try {
|
||||
const response = await fetch(this.API_CHECK);
|
||||
|
|
@ -711,6 +795,7 @@ class VideoDisplayRenderer {
|
|||
constructor(container, data, onComplete) {
|
||||
this.container = container;
|
||||
this.data = data;
|
||||
this.settings = data.settings || {};
|
||||
this.onComplete = onComplete;
|
||||
this.videos = data.videoPlaylist || [];
|
||||
this.footerContent = data.footerContent || [];
|
||||
|
|
@ -752,7 +837,7 @@ class VideoDisplayRenderer {
|
|||
</div>
|
||||
<div class="vd-qr">
|
||||
<img alt="QR">
|
||||
<span class="vd-qr-label">Website</span>
|
||||
<span class="vd-qr-label">${escapeHtml(this.settings.qr_label || 'Website')}</span>
|
||||
</div>
|
||||
`;
|
||||
layer.appendChild(this.footerEl);
|
||||
|
|
@ -880,7 +965,10 @@ class VideoDisplayRenderer {
|
|||
}
|
||||
|
||||
resolveAssetUrl(src) {
|
||||
if (!src) return '';
|
||||
if (src.startsWith('http')) return src;
|
||||
if (src.startsWith('/')) return src;
|
||||
if (src.startsWith('../')) return src;
|
||||
// Assets relative to _cabinet
|
||||
return `../${src}`;
|
||||
}
|
||||
|
|
@ -929,10 +1017,20 @@ class B2inRenderer {
|
|||
layer.className = 'version-layer b2in-layer active';
|
||||
layer.setAttribute('data-theme', this.theme);
|
||||
|
||||
const headerLogoUrl = this.resolveUrl(this.settings.header_logo_url || '../assets/b2in-logo-positive.svg');
|
||||
const headerClaim = this.settings.header_claim || 'Connecting Design & Property';
|
||||
const footerUrl = this.settings.footer_url || 'B2in.eu';
|
||||
const footerName = this.settings.footer_name || '';
|
||||
const footerPrefix = this.settings.footer_prefix || 'by';
|
||||
const footerNameHtml = footerName
|
||||
? `<span class="b2in-footer-name">${escapeHtml(footerPrefix ? `${footerPrefix} ${footerName}` : footerName)}</span>`
|
||||
: '';
|
||||
const qrUrl = normalizeQrUrl(this.settings.qr_url || footerUrl || 'b2in.eu');
|
||||
|
||||
layer.innerHTML = `
|
||||
<header class="b2in-header">
|
||||
<img src="../assets/b2in-logo-positive.svg" alt="B2in">
|
||||
<span class="b2in-claim">Connecting Design & Property</span>
|
||||
<img src="${escapeHtml(headerLogoUrl)}" alt="B2in">
|
||||
<span class="b2in-claim">${escapeHtml(headerClaim)}</span>
|
||||
</header>
|
||||
<section class="b2in-media">
|
||||
<div class="b2in-media-layer active" id="b2in-layer-a"></div>
|
||||
|
|
@ -944,11 +1042,11 @@ class B2inRenderer {
|
|||
</section>
|
||||
<footer class="b2in-footer">
|
||||
<div>
|
||||
<span class="b2in-footer-url">${this.settings.footer_url || 'B2in.eu'}</span>
|
||||
<span class="b2in-footer-name">by ${this.settings.footer_name || ''}</span>
|
||||
<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('https://' + (this.settings.footer_url || 'b2in.eu'))}" alt="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">
|
||||
|
|
@ -1071,6 +1169,7 @@ class B2inRenderer {
|
|||
resolveUrl(url) {
|
||||
if (!url) return '';
|
||||
if (url.startsWith('http')) return url;
|
||||
if (url.startsWith('/')) return url;
|
||||
if (url.startsWith('../')) return url;
|
||||
return `../${url}`;
|
||||
}
|
||||
|
|
@ -1147,10 +1246,12 @@ class OffersRenderer {
|
|||
const cw = this.container.clientWidth || OffersRenderer.DESIGN_W;
|
||||
const ch = this.container.clientHeight || OffersRenderer.DESIGN_H;
|
||||
const scale = Math.min(cw / OffersRenderer.DESIGN_W, ch / OffersRenderer.DESIGN_H);
|
||||
const offsetX = Math.max(0, (cw - OffersRenderer.DESIGN_W * scale) / 2);
|
||||
const offsetY = Math.max(0, (ch - OffersRenderer.DESIGN_H * scale) / 2);
|
||||
|
||||
this.slideArticles.forEach(article => {
|
||||
if (article) {
|
||||
article.style.transform = `scale(${scale})`;
|
||||
article.style.transform = `translate(${offsetX}px, ${offsetY}px) scale(${scale})`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -1168,12 +1269,16 @@ class OffersRenderer {
|
|||
|
||||
const brand = document.createElement('div');
|
||||
brand.className = 'offer-brand';
|
||||
brand.innerHTML = '<img src="../logo-cabinet-300.png" alt="CABINET" class="offer-brand-logo">';
|
||||
const brandLogo = document.createElement('img');
|
||||
brandLogo.src = this.resolveUrl(this.settings.logo_url || '../logo-cabinet-300.png');
|
||||
brandLogo.alt = 'CABINET';
|
||||
brandLogo.className = 'offer-brand-logo';
|
||||
brand.appendChild(brandLogo);
|
||||
|
||||
if (slide.show_brand_text) {
|
||||
const brandText = document.createElement('span');
|
||||
brandText.className = 'offer-brand-text';
|
||||
brandText.textContent = 'Bielefeld';
|
||||
brandText.textContent = this.settings.brand_text || 'Bielefeld';
|
||||
brand.appendChild(brandText);
|
||||
}
|
||||
|
||||
|
|
@ -1308,25 +1413,27 @@ class OffersRenderer {
|
|||
const qrHeader = document.createElement('div');
|
||||
qrHeader.className = 'offer-qr-header';
|
||||
qrHeader.innerHTML = `
|
||||
<p class="offer-qr-title">${this.escapeHtml(slide.qr_title || 'Kontakt')}</p>
|
||||
<p class="offer-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);
|
||||
|
||||
const qrWrapper = document.createElement('div');
|
||||
qrWrapper.className = 'offer-qr-wrapper';
|
||||
if (slide.qr_url) {
|
||||
const qrUrl = slide.qr_url || this.settings.footer_url || '';
|
||||
if (qrUrl) {
|
||||
const qrImg = document.createElement('img');
|
||||
qrImg.src = `https://api.qrserver.com/v1/create-qr-code/?size=300x300&color=000000&bgcolor=ffffff&margin=8&data=${encodeURIComponent(slide.qr_url)}`;
|
||||
qrImg.src = `https://api.qrserver.com/v1/create-qr-code/?size=300x300&color=000000&bgcolor=ffffff&margin=8&data=${encodeURIComponent(normalizeQrUrl(qrUrl))}`;
|
||||
qrImg.alt = 'QR Code';
|
||||
qrWrapper.appendChild(qrImg);
|
||||
}
|
||||
qrBox.appendChild(qrWrapper);
|
||||
|
||||
if (slide.contact) {
|
||||
const contactText = slide.contact || this.settings.footer_claim || '';
|
||||
if (contactText) {
|
||||
const contact = document.createElement('p');
|
||||
contact.className = 'offer-qr-contact';
|
||||
contact.innerHTML = slide.contact.replace(/\n/g, '<br>');
|
||||
contact.innerHTML = contactText.replace(/\n/g, '<br>');
|
||||
qrBox.appendChild(contact);
|
||||
}
|
||||
|
||||
|
|
@ -1388,6 +1495,7 @@ class OffersRenderer {
|
|||
resolveUrl(url) {
|
||||
if (!url) return '';
|
||||
if (url.startsWith('http')) return url;
|
||||
if (url.startsWith('/')) return url;
|
||||
if (url.startsWith('../')) return url;
|
||||
return `../${url}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -183,7 +183,7 @@
|
|||
|
||||
<flux:navlist.group :heading="__('Cabinet')" class="grid mb-4">
|
||||
<flux:navlist.group expandable
|
||||
:expanded="request()->routeIs(['admin.cms.display-dashboard', 'admin.cms.display-media', '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.display-versions', 'admin.cms.display-version-edit', 'admin.cms.displays', 'admin.cms.cabinet', 'admin.cms.cabinet-tablet'])"
|
||||
heading="Store Displays" class="grid">
|
||||
<flux:navlist.item icon="squares-2x2" :href="route('admin.cms.display-dashboard')"
|
||||
:current="request()->routeIs('admin.cms.display-dashboard')" wire:navigate>{{ __('Übersicht') }}
|
||||
|
|
@ -191,8 +191,8 @@
|
|||
<flux:navlist.item icon="photo" :href="route('admin.cms.display-media')"
|
||||
:current="request()->routeIs('admin.cms.display-media')" wire:navigate>{{ __('Mediathek') }}
|
||||
</flux:navlist.item>
|
||||
<flux:navlist.item icon="rectangle-group" :href="route('admin.cms.display-versions')"
|
||||
:current="request()->routeIs(['admin.cms.display-versions', 'admin.cms.display-version-edit'])" wire:navigate>{{ __('Versionen') }}
|
||||
<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') }}
|
||||
</flux:navlist.item>
|
||||
<flux:navlist.item icon="tv" :href="route('admin.cms.displays')"
|
||||
:current="request()->routeIs('admin.cms.displays')" wire:navigate>{{ __('Displays') }}
|
||||
|
|
|
|||
|
|
@ -14,8 +14,8 @@ title('Store Displays');
|
|||
$stats = computed(fn () => [
|
||||
'displays' => Display::count(),
|
||||
'displays_active' => Display::where('is_active', true)->count(),
|
||||
'versions' => DisplayVersion::count(),
|
||||
'versions_active' => DisplayVersion::active()->count(),
|
||||
'modules' => DisplayVersion::count(),
|
||||
'modules_active' => DisplayVersion::active()->count(),
|
||||
'items' => DisplayVersionItem::count(),
|
||||
'items_active' => DisplayVersionItem::where('is_active', true)->count(),
|
||||
'type_video' => DisplayVersion::ofType(DisplayVersionType::VideoDisplay)->count(),
|
||||
|
|
@ -41,7 +41,7 @@ $tabletStatus = computed(function () {
|
|||
<div>
|
||||
<div class="mb-6">
|
||||
<flux:heading size="xl">Store Displays</flux:heading>
|
||||
<flux:text class="mt-1">Displays, Inhalts-Versionen und Info-Tablet im Cabinet Showroom verwalten.</flux:text>
|
||||
<flux:text class="mt-1">Displays, Inhalts-Module und Info-Tablet im Cabinet Showroom verwalten.</flux:text>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-5">
|
||||
|
|
@ -57,13 +57,13 @@ $tabletStatus = computed(function () {
|
|||
</flux:card>
|
||||
</a>
|
||||
|
||||
<a href="{{ route('admin.cms.display-versions') }}" wire:navigate>
|
||||
<a href="{{ route('admin.cms.display-modules') }}" wire:navigate>
|
||||
<flux:card class="hover:border-purple-500 transition-colors">
|
||||
<div class="flex items-center gap-3">
|
||||
<flux:icon name="rectangle-group" class="text-purple-500" />
|
||||
<div>
|
||||
<flux:heading size="lg">{{ $this->stats['versions'] }}</flux:heading>
|
||||
<flux:text class="text-sm">Versionen ({{ $this->stats['versions_active'] }} aktiv)</flux:text>
|
||||
<flux:heading size="lg">{{ $this->stats['modules'] }}</flux:heading>
|
||||
<flux:text class="text-sm">Module ({{ $this->stats['modules_active'] }} aktiv)</flux:text>
|
||||
</div>
|
||||
</div>
|
||||
</flux:card>
|
||||
|
|
@ -125,14 +125,14 @@ $tabletStatus = computed(function () {
|
|||
</flux:card>
|
||||
</div>
|
||||
|
||||
{{-- Versions-Typen Übersicht --}}
|
||||
{{-- Modul-Typen Übersicht --}}
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3 mt-4">
|
||||
<flux:card>
|
||||
<div class="flex items-center gap-3">
|
||||
<flux:icon name="film" class="text-purple-400" />
|
||||
<div>
|
||||
<flux:text class="text-sm font-medium text-zinc-800 dark:text-zinc-200">Video-Display</flux:text>
|
||||
<flux:text class="text-xs">{{ $this->stats['type_video'] }} {{ $this->stats['type_video'] === 1 ? 'Version' : 'Versionen' }}</flux:text>
|
||||
<flux:text class="text-xs">{{ $this->stats['type_video'] }} {{ $this->stats['type_video'] === 1 ? 'Modul' : 'Module' }}</flux:text>
|
||||
</div>
|
||||
</div>
|
||||
</flux:card>
|
||||
|
|
@ -141,7 +141,7 @@ $tabletStatus = computed(function () {
|
|||
<flux:icon name="photo" class="text-blue-400" />
|
||||
<div>
|
||||
<flux:text class="text-sm font-medium text-zinc-800 dark:text-zinc-200">B2in Display</flux:text>
|
||||
<flux:text class="text-xs">{{ $this->stats['type_b2in'] }} {{ $this->stats['type_b2in'] === 1 ? 'Version' : 'Versionen' }}</flux:text>
|
||||
<flux:text class="text-xs">{{ $this->stats['type_b2in'] }} {{ $this->stats['type_b2in'] === 1 ? 'Modul' : 'Module' }}</flux:text>
|
||||
</div>
|
||||
</div>
|
||||
</flux:card>
|
||||
|
|
@ -150,7 +150,7 @@ $tabletStatus = computed(function () {
|
|||
<flux:icon name="tag" class="text-amber-400" />
|
||||
<div>
|
||||
<flux:text class="text-sm font-medium text-zinc-800 dark:text-zinc-200">Angebote</flux:text>
|
||||
<flux:text class="text-xs">{{ $this->stats['type_offers'] }} {{ $this->stats['type_offers'] === 1 ? 'Version' : 'Versionen' }}</flux:text>
|
||||
<flux:text class="text-xs">{{ $this->stats['type_offers'] }} {{ $this->stats['type_offers'] === 1 ? 'Modul' : 'Module' }}</flux:text>
|
||||
</div>
|
||||
</div>
|
||||
</flux:card>
|
||||
|
|
@ -172,8 +172,8 @@ $tabletStatus = computed(function () {
|
|||
</p>
|
||||
<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">Versionen</strong> – Content-Pakete, die auf den Displays abgespielt werden. Jede Version 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">Displays</strong> – Die physischen Bildschirme im Showroom. Jedem Display werden eine oder mehrere Versionen als Playlist zugewiesen.</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">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">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>
|
||||
</div>
|
||||
|
|
@ -191,9 +191,9 @@ $tabletStatus = computed(function () {
|
|||
</p>
|
||||
<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">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 Versionen 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">Medienauswahl im Editor:</strong> Beim Bearbeiten einer Version 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 hochladen.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
|
|
@ -202,10 +202,10 @@ $tabletStatus = computed(function () {
|
|||
<div>
|
||||
<flux:heading size="sm" class="mb-1 flex items-center gap-2">
|
||||
<flux:icon name="rectangle-group" class="size-4 text-purple-500" />
|
||||
Versionen & Versions-Typen
|
||||
Module & Modul-Typen
|
||||
</flux:heading>
|
||||
<p>
|
||||
Eine <strong class="font-medium text-zinc-800 dark:text-zinc-200">Version</strong> ist ein Content-Paket mit einem bestimmten Typ.
|
||||
Ein <strong class="font-medium text-zinc-800 dark:text-zinc-200">Modul</strong> ist ein Content-Paket mit einem bestimmten Typ.
|
||||
Der Typ bestimmt, welche Art von Inhalten hinzugefügt werden können:
|
||||
</p>
|
||||
<ul class="mt-2 ml-5 list-disc space-y-1">
|
||||
|
|
@ -223,7 +223,7 @@ $tabletStatus = computed(function () {
|
|||
</li>
|
||||
</ul>
|
||||
<p class="mt-2">
|
||||
Innerhalb einer Version 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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -238,7 +238,7 @@ $tabletStatus = computed(function () {
|
|||
Ein <strong class="font-medium text-zinc-800 dark:text-zinc-200">Display</strong> repräsentiert einen physischen Bildschirm im Showroom.
|
||||
</p>
|
||||
<ul class="mt-2 ml-5 list-disc space-y-1">
|
||||
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Versions-Zuweisung:</strong> Jedem Display können Sie eine oder mehrere Versionen zuordnen. Die Versionen werden in der festgelegten Reihenfolge als Playlist abgespielt.</li>
|
||||
<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">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>
|
||||
</ul>
|
||||
|
|
@ -282,9 +282,9 @@ $tabletStatus = computed(function () {
|
|||
Typischer Workflow
|
||||
</flux:heading>
|
||||
<ol class="mt-2 ml-5 list-decimal space-y-1">
|
||||
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Version erstellen</strong> – Unter „Versionen" eine neue Version 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> – In der Version Videos, Medien oder Slides anlegen, Reihenfolge festlegen und aktivieren.</li>
|
||||
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Display zuweisen</strong> – Unter „Displays" die Version einem physischen Bildschirm zuordnen.</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">Display zuweisen</strong> – Unter „Displays" das Modul einem physischen Bildschirm zuordnen.</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>
|
||||
</ol>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<div>
|
||||
<flux:header class="mb-6">
|
||||
<flux:heading size="xl">{{ __('Displays') }}</flux:heading>
|
||||
<flux:subheading>{{ __('Verwalten Sie Ihre physischen Displays und weisen Sie ihnen Versionen zu') }}</flux:subheading>
|
||||
<flux:subheading>{{ __('Verwalten Sie Live-Bespielungen und Entwürfe je physischem Display') }}</flux:subheading>
|
||||
</flux:header>
|
||||
|
||||
@if (session()->has('success'))
|
||||
|
|
@ -14,7 +14,7 @@
|
|||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<flux:heading size="lg">{{ __('Physische Displays') }}</flux:heading>
|
||||
<flux:subheading>{{ __('Jedem Display können mehrere Versionen als Playlist zugewiesen werden') }}</flux:subheading>
|
||||
<flux:subheading>{{ __('Live bleibt stabil, Entwürfe können vorbereitet und gezielt veröffentlicht werden') }}</flux:subheading>
|
||||
</div>
|
||||
<flux:button wire:click="openModal" icon="plus" variant="primary">
|
||||
{{ __('Display hinzufügen') }}
|
||||
|
|
@ -27,57 +27,32 @@
|
|||
<p>{{ __('Noch keine Displays vorhanden. Fügen Sie Ihr erstes Display hinzu!') }}</p>
|
||||
</div>
|
||||
@else
|
||||
<div class="space-y-3">
|
||||
<div class="space-y-4">
|
||||
@foreach($displays as $display)
|
||||
@php
|
||||
$liveDisplayUrl = url('/_cabinet/display/index.html').'?id='.$display->id;
|
||||
$liveApiUrl = url('/api/display/'.$display->id.'/config');
|
||||
@endphp
|
||||
<div wire:key="display-{{ $display->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 hover:border-zinc-300 dark:hover:border-zinc-600 transition">
|
||||
class="rounded-xl border p-4 transition {{ $display->is_test ? 'border-amber-300 bg-amber-50/70 dark:border-amber-500/50 dark:bg-amber-950/20' : 'border-zinc-200 bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-800' }}">
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-3 mb-1">
|
||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div class="min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<flux:badge :color="$display->is_active ? 'green' : 'zinc'" size="sm">
|
||||
{{ $display->is_active ? __('Aktiv') : __('Inaktiv') }}
|
||||
</flux:badge>
|
||||
<span class="font-semibold text-sm">{{ $display->name }}</span>
|
||||
@if($display->is_test)
|
||||
<flux:badge color="amber" size="sm">{{ __('Test-Display') }}</flux:badge>
|
||||
@endif
|
||||
<span class="font-semibold text-sm text-zinc-900 dark:text-zinc-100">{{ $display->name }}</span>
|
||||
@if($display->location)
|
||||
<span class="text-xs text-zinc-500 dark:text-zinc-400">{{ $display->location }}</span>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if($display->versions->isNotEmpty())
|
||||
<div class="flex flex-wrap items-center gap-1.5 mt-2">
|
||||
@foreach($display->versions as $idx => $version)
|
||||
@if($idx > 0)
|
||||
<flux:icon.chevron-right class="w-3 h-3 text-zinc-400" />
|
||||
@endif
|
||||
<flux:badge color="{{ match($version->type->value) {
|
||||
'video-display' => 'purple',
|
||||
'b2in' => 'blue',
|
||||
'offers' => 'amber',
|
||||
} }}" size="sm">
|
||||
{{ $version->name }}
|
||||
</flux:badge>
|
||||
@endforeach
|
||||
</div>
|
||||
@else
|
||||
<div class="text-xs text-amber-600 dark:text-amber-400 mt-1">
|
||||
{{ __('Keine Versionen zugewiesen') }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Direct display links --}}
|
||||
<div class="mt-2 flex items-center gap-4">
|
||||
<a href="/_cabinet/display/index.html?id={{ $display->id }}"
|
||||
target="_blank"
|
||||
class="text-xs text-blue-600 dark:text-blue-400 hover:underline inline-flex items-center gap-1">
|
||||
<flux:icon.play class="w-3 h-3" />
|
||||
Display öffnen
|
||||
</a>
|
||||
<a href="/api/display/{{ $display->id }}/config"
|
||||
target="_blank"
|
||||
class="text-xs text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-300 hover:underline inline-flex items-center gap-1">
|
||||
<flux:icon.code-bracket class="w-3 h-3" />
|
||||
API
|
||||
</a>
|
||||
<div class="mt-2 text-xs text-zinc-500 dark:text-zinc-400">
|
||||
{{ __('Display-ID') }}: {{ $display->id }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -88,12 +63,6 @@
|
|||
:icon="$display->is_active ? 'eye-slash' : 'eye'">
|
||||
</flux:button>
|
||||
|
||||
<flux:button wire:click="openModal({{ $display->id }})"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
icon="pencil">
|
||||
</flux:button>
|
||||
|
||||
<flux:button wire:click="deleteDisplay({{ $display->id }})"
|
||||
wire:confirm="Möchten Sie dieses Display wirklich löschen?"
|
||||
size="sm"
|
||||
|
|
@ -103,6 +72,158 @@
|
|||
</flux:button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 grid gap-4 lg:grid-cols-2">
|
||||
<div class="rounded-lg border border-zinc-200 bg-white p-4 dark:border-zinc-700 dark:bg-zinc-900/60">
|
||||
<div class="mb-3 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-green-600 dark:text-green-400">{{ __('Live') }}</div>
|
||||
<div class="text-xs text-zinc-500 dark:text-zinc-400">
|
||||
{{ optional($display->livePlaylist?->updated_at)->format('d.m.Y H:i') ?? __('Noch nicht veröffentlicht') }}
|
||||
</div>
|
||||
</div>
|
||||
<flux:badge color="green" size="sm">{{ __('Veröffentlicht') }}</flux:badge>
|
||||
</div>
|
||||
|
||||
@if($display->livePlaylist?->modules->isNotEmpty())
|
||||
<div class="flex flex-wrap items-center gap-1.5">
|
||||
@foreach($display->livePlaylist->modules as $idx => $module)
|
||||
@if($idx > 0)
|
||||
<flux:icon.chevron-right class="w-3 h-3 text-zinc-400" />
|
||||
@endif
|
||||
<flux:badge color="{{ match($module->type->value) {
|
||||
'video-display' => 'purple',
|
||||
'b2in' => 'blue',
|
||||
'offers' => 'amber',
|
||||
} }}" size="sm">
|
||||
{{ $module->name }}
|
||||
</flux:badge>
|
||||
@endforeach
|
||||
</div>
|
||||
@else
|
||||
<div class="rounded border border-dashed border-amber-300 p-3 text-xs text-amber-700 dark:border-amber-500/60 dark:text-amber-300">
|
||||
{{ __('Keine Live-Bespielung vorhanden') }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="mt-4 flex flex-wrap items-center gap-3">
|
||||
<flux:button wire:click="openModal({{ $display->id }}, 'published')"
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
icon="pencil">
|
||||
{{ __('Live bearbeiten') }}
|
||||
</flux:button>
|
||||
<a href="{{ $liveDisplayUrl }}"
|
||||
target="_blank"
|
||||
class="inline-flex items-center gap-1 text-xs text-blue-600 hover:underline dark:text-blue-400">
|
||||
<flux:icon.play class="w-3 h-3" />
|
||||
{{ __('Vorschau') }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<label class="mb-1 block text-xs font-medium text-zinc-500 dark:text-zinc-400">
|
||||
{{ __('Live-URL zum Kopieren') }}
|
||||
</label>
|
||||
<div class="flex gap-2">
|
||||
<input type="text"
|
||||
readonly
|
||||
value="{{ $liveDisplayUrl }}"
|
||||
onclick="this.select()"
|
||||
class="min-w-0 flex-1 rounded-md border border-zinc-200 bg-zinc-50 px-2 py-1.5 text-xs text-zinc-700 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-200">
|
||||
<button type="button"
|
||||
onclick="navigator.clipboard?.writeText(@js($liveDisplayUrl))"
|
||||
class="rounded-md border border-zinc-200 px-2 py-1.5 text-xs text-zinc-600 hover:bg-zinc-50 dark:border-zinc-700 dark:text-zinc-300 dark:hover:bg-zinc-800">
|
||||
{{ __('Kopieren') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 flex justify-end">
|
||||
<a href="{{ $liveApiUrl }}"
|
||||
target="_blank"
|
||||
class="inline-flex items-center gap-1 text-xs text-zinc-400 hover:text-zinc-600 hover:underline dark:hover:text-zinc-300">
|
||||
<flux:icon.code-bracket class="w-3 h-3" />
|
||||
{{ __('API') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-dashed border-zinc-300 bg-white p-4 dark:border-zinc-600 dark:bg-zinc-900/60">
|
||||
<div class="mb-3 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-amber-600 dark:text-amber-400">{{ __('Entwurf') }}</div>
|
||||
<div class="text-xs text-zinc-500 dark:text-zinc-400">
|
||||
{{ optional($display->draftPlaylist?->updated_at)->format('d.m.Y H:i') ?? __('Kein Entwurf') }}
|
||||
</div>
|
||||
</div>
|
||||
<flux:badge :color="$display->draftPlaylist ? 'amber' : 'zinc'" size="sm">
|
||||
{{ $display->draftPlaylist ? __('In Arbeit') : __('Leer') }}
|
||||
</flux:badge>
|
||||
</div>
|
||||
|
||||
@if($display->draftPlaylist)
|
||||
@if($display->draftPlaylist->modules->isNotEmpty())
|
||||
<div class="flex flex-wrap items-center gap-1.5">
|
||||
@foreach($display->draftPlaylist->modules as $idx => $module)
|
||||
@if($idx > 0)
|
||||
<flux:icon.chevron-right class="w-3 h-3 text-zinc-400" />
|
||||
@endif
|
||||
<flux:badge color="{{ match($module->type->value) {
|
||||
'video-display' => 'purple',
|
||||
'b2in' => 'blue',
|
||||
'offers' => 'amber',
|
||||
} }}" size="sm">
|
||||
{{ $module->name }}
|
||||
</flux:badge>
|
||||
@endforeach
|
||||
</div>
|
||||
@else
|
||||
<div class="rounded border border-dashed border-zinc-300 p-3 text-xs text-zinc-500 dark:border-zinc-600 dark:text-zinc-400">
|
||||
{{ __('Entwurf ist leer') }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="mt-4 flex flex-wrap items-center gap-3">
|
||||
<flux:button wire:click="openModal({{ $display->id }}, 'draft')"
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
icon="pencil">
|
||||
{{ __('Entwurf bearbeiten') }}
|
||||
</flux:button>
|
||||
<a href="/preview/{{ $display->preview_token }}"
|
||||
target="_blank"
|
||||
class="inline-flex items-center gap-1 text-xs text-blue-600 hover:underline dark:text-blue-400">
|
||||
<flux:icon.play class="w-3 h-3" />
|
||||
{{ __('Test-URL') }}
|
||||
</a>
|
||||
<flux:button wire:click="publishDraft({{ $display->id }})"
|
||||
wire:confirm="Diesen Entwurf veröffentlichen und den Live-Stand ersetzen?"
|
||||
size="xs"
|
||||
variant="primary">
|
||||
{{ __('Veröffentlichen') }}
|
||||
</flux:button>
|
||||
<flux:button wire:click="discardDraft({{ $display->id }})"
|
||||
wire:confirm="Diesen Entwurf wirklich verwerfen?"
|
||||
size="xs"
|
||||
variant="ghost">
|
||||
{{ __('Verwerfen') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
@else
|
||||
<div class="rounded border border-dashed border-zinc-300 p-3 text-xs text-zinc-500 dark:border-zinc-600 dark:text-zinc-400">
|
||||
{{ __('Noch kein Entwurf. Beim Anlegen wird der Live-Stand kopiert.') }}
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<flux:button wire:click="createDraft({{ $display->id }})" size="sm" variant="ghost" icon="document-plus">
|
||||
{{ __('Entwurf anlegen') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
|
|
@ -111,9 +232,23 @@
|
|||
{{-- Display Modal --}}
|
||||
<flux:modal :open="$showModal" wire:model="showModal">
|
||||
<form wire:submit.prevent="save">
|
||||
@php
|
||||
$isDraftEditor = $displayId && $editingPlaylistStatus === \App\Models\DisplayPlaylist::STATUS_DRAFT;
|
||||
$draftPreviewUrl = $draftPreviewToken ? url('/preview/'.$draftPreviewToken).'?refresh='.$previewFrameRefreshCounter : null;
|
||||
@endphp
|
||||
|
||||
<div>
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<flux:heading size="lg">{{ $displayId ? __('Display bearbeiten') : __('Display hinzufügen') }}</flux:heading>
|
||||
<flux:heading size="lg">
|
||||
@if(! $displayId)
|
||||
{{ __('Display hinzufügen') }}
|
||||
@elseif($isDraftEditor)
|
||||
{{ __('Entwurf bearbeiten') }}
|
||||
@else
|
||||
{{ __('Live-Bespielung bearbeiten') }}
|
||||
@endif
|
||||
</flux:heading>
|
||||
</div>
|
||||
|
||||
<flux:input wire:model="displayName" label="Name" placeholder="z.B. Display 1 - Eingang" />
|
||||
|
|
@ -123,8 +258,10 @@
|
|||
|
||||
{{-- Version Playlist --}}
|
||||
<div>
|
||||
<flux:heading size="sm" class="mb-2">{{ __('Versions-Playlist') }}</flux:heading>
|
||||
<flux:subheading class="mb-3">{{ __('Versionen werden in dieser Reihenfolge als Schleife abgespielt') }}</flux:subheading>
|
||||
<flux:heading size="sm" class="mb-2">
|
||||
{{ $isDraftEditor ? __('Entwurfs-Bespielung') : __('Live-Bespielung') }}
|
||||
</flux:heading>
|
||||
<flux:subheading class="mb-3">{{ __('Module werden in dieser Reihenfolge als Schleife abgespielt') }}</flux:subheading>
|
||||
|
||||
@if(count($selectedVersionIds) > 0)
|
||||
<div class="space-y-2 mb-3">
|
||||
|
|
@ -144,18 +281,21 @@
|
|||
<span class="text-sm flex-1">{{ $ver->name }}</span>
|
||||
<div class="flex items-center gap-1">
|
||||
<flux:button wire:click="moveVersion({{ $index }}, 'up')"
|
||||
type="button"
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
icon="chevron-up"
|
||||
:disabled="$index === 0">
|
||||
</flux:button>
|
||||
<flux:button wire:click="moveVersion({{ $index }}, 'down')"
|
||||
type="button"
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
icon="chevron-down"
|
||||
:disabled="$index === count($selectedVersionIds) - 1">
|
||||
</flux:button>
|
||||
<flux:button wire:click="removeVersion({{ $index }})"
|
||||
type="button"
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
icon="x-mark"
|
||||
|
|
@ -168,27 +308,39 @@
|
|||
</div>
|
||||
@else
|
||||
<div class="text-center py-4 text-zinc-400 text-sm border border-dashed border-zinc-300 dark:border-zinc-600 rounded mb-3">
|
||||
{{ __('Noch keine Versionen hinzugefügt') }}
|
||||
{{ __('Noch keine Module hinzugefügt') }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@php
|
||||
$availableVersions = $versions->reject(fn ($version) => in_array($version->id, $selectedVersionIds, true));
|
||||
@endphp
|
||||
|
||||
@if($availableVersions->isNotEmpty())
|
||||
<div class="flex gap-2">
|
||||
<div class="flex-1">
|
||||
<flux:select wire:model="addVersionSelect" placeholder="Version hinzufügen...">
|
||||
@foreach($versions as $version)
|
||||
<flux:select wire:model="addVersionSelect" placeholder="Modul hinzufügen...">
|
||||
@foreach($availableVersions as $version)
|
||||
<option value="{{ $version->id }}">{{ $version->name }} ({{ $version->type->label() }})</option>
|
||||
@endforeach
|
||||
</flux:select>
|
||||
</div>
|
||||
<flux:button wire:click="addVersion"
|
||||
type="button"
|
||||
icon="plus"
|
||||
size="sm"
|
||||
variant="ghost">
|
||||
</flux:button>
|
||||
</div>
|
||||
@else
|
||||
<div class="rounded border border-dashed border-zinc-300 p-3 text-xs text-zinc-500 dark:border-zinc-600 dark:text-zinc-400">
|
||||
{{ __('Alle verfügbaren Module sind bereits hinzugefügt.') }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<flux:checkbox wire:model="displayIsActive" label="Display aktiv" />
|
||||
<flux:checkbox wire:model="displayIsTest" label="Als Test-Display hervorheben" />
|
||||
|
||||
<div class="flex justify-end gap-3 pt-4">
|
||||
<flux:button type="button" wire:click="closeModal" variant="ghost">
|
||||
|
|
@ -198,6 +350,40 @@
|
|||
{{ $displayId ? __('Aktualisieren') : __('Hinzufügen') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
|
||||
@if($isDraftEditor)
|
||||
<div class="space-y-3 border-t border-zinc-200 pt-6 dark:border-zinc-700">
|
||||
<div>
|
||||
<flux:heading size="sm">{{ __('Live-Vorschau') }}</flux:heading>
|
||||
<flux:subheading>{{ __('Aktualisiert sich nach Modul-Änderungen automatisch') }}</flux:subheading>
|
||||
</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">
|
||||
@if($draftPreviewUrl)
|
||||
<iframe
|
||||
wire:key="draft-preview-{{ $previewFrameRefreshCounter }}"
|
||||
src="{{ $draftPreviewUrl }}"
|
||||
class="h-full w-full border-0"
|
||||
title="{{ __('Entwurfs-Vorschau') }}"
|
||||
></iframe>
|
||||
@else
|
||||
<div class="flex h-full items-center justify-center p-6 text-center text-xs text-zinc-400">
|
||||
{{ __('Für diesen Entwurf ist noch keine Vorschau-URL verfügbar.') }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if($draftPreviewUrl)
|
||||
<a href="{{ $draftPreviewUrl }}"
|
||||
target="_blank"
|
||||
class="mx-auto flex max-w-[320px] items-center justify-center gap-1 text-xs text-blue-600 hover:underline dark:text-blue-400">
|
||||
<flux:icon.arrow-top-right-on-square class="w-3 h-3" />
|
||||
{{ __('Vollbild öffnen') }}
|
||||
</a>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</flux:modal>
|
||||
|
|
|
|||
|
|
@ -77,7 +77,7 @@ $stats = computed(fn () => [
|
|||
$handleUploads = function () {
|
||||
$this->validate([
|
||||
'uploads' => 'nullable|array|max:10',
|
||||
'uploads.*' => 'file|mimes:jpeg,jpg,png,gif,webp,mp4,webm,mov|max:204800',
|
||||
'uploads.*' => 'file|mimes:jpeg,jpg,png,gif,webp,svg,mp4,webm,mov|max:204800',
|
||||
]);
|
||||
|
||||
$service = app(DisplayMediaService::class);
|
||||
|
|
@ -205,10 +205,10 @@ $closeDetail = function () {
|
|||
<div class="flex items-start gap-4">
|
||||
<div class="flex-1">
|
||||
<flux:file-upload wire:model="uploads" multiple
|
||||
accept="image/jpeg,image/png,image/gif,image/webp,video/mp4,video/webm,.jpg,.jpeg,.png,.webp,.mp4,.webm,.mov">
|
||||
accept="image/jpeg,image/png,image/gif,image/webp,image/svg+xml,video/mp4,video/webm,.jpg,.jpeg,.png,.gif,.webp,.svg,.mp4,.webm,.mov">
|
||||
<flux:file-upload.dropzone
|
||||
heading="Dateien hochladen"
|
||||
text="Bilder & Videos bis 200 MB - Drag & Drop oder klicken"
|
||||
text="Bilder inkl. SVG & Videos bis 200 MB - Drag & Drop oder klicken"
|
||||
with-progress />
|
||||
</flux:file-upload>
|
||||
@if (isset($uploads) && count($uploads) > 0)
|
||||
|
|
|
|||
|
|
@ -54,10 +54,10 @@
|
|||
<flux:input wire:model.live.debounce.300ms="search" placeholder="Suchen (Dateiname oder Titel)..." icon="magnifying-glass" size="sm" />
|
||||
|
||||
<flux:file-upload wire:model="quickUploads" multiple
|
||||
accept="image/jpeg,image/png,image/gif,image/webp,video/mp4,video/webm,.jpg,.jpeg,.png,.webp,.mp4,.webm,.mov">
|
||||
accept="image/jpeg,image/png,image/gif,image/webp,image/svg+xml,video/mp4,video/webm,.jpg,.jpeg,.png,.gif,.webp,.svg,.mp4,.webm,.mov">
|
||||
<flux:file-upload.dropzone
|
||||
heading="Datei hochladen"
|
||||
text="Bilder und Videos bis 200 MB"
|
||||
text="Bilder inkl. SVG und Videos bis 200 MB"
|
||||
with-progress />
|
||||
</flux:file-upload>
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
{{-- Header --}}
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div class="flex items-center gap-4">
|
||||
<flux:button :href="route('admin.cms.display-versions')" wire:navigate variant="ghost" icon="arrow-left" size="sm">
|
||||
<flux:button :href="route('admin.cms.display-modules')" wire:navigate variant="ghost" icon="arrow-left" size="sm">
|
||||
{{ __('Zurück') }}
|
||||
</flux:button>
|
||||
<div>
|
||||
|
|
@ -16,7 +16,7 @@
|
|||
{{ $version->type->label() }}
|
||||
</flux:badge>
|
||||
</div>
|
||||
<flux:subheading>{{ __('Version bearbeiten') }}</flux:subheading>
|
||||
<flux:subheading>{{ __('Modul bearbeiten') }}</flux:subheading>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
|
|
@ -44,12 +44,37 @@
|
|||
<flux:card class="mb-6">
|
||||
<form wire:submit.prevent="saveName" class="flex items-end gap-4">
|
||||
<div class="flex-1">
|
||||
<flux:input wire:model="versionName" label="Versionsname" />
|
||||
<flux:input wire:model="versionName" label="Modulname" />
|
||||
</div>
|
||||
<flux:button type="submit" variant="primary" size="sm">{{ __('Speichern') }}</flux:button>
|
||||
</form>
|
||||
</flux:card>
|
||||
|
||||
{{-- Modul-Vorschau --}}
|
||||
<flux:card class="mb-6">
|
||||
<div class="mb-4 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<flux:heading size="lg">{{ __('Modul-Vorschau') }}</flux:heading>
|
||||
<flux:subheading>{{ __('Live gerenderte Einzelmodul-Vorschau im Display-Player') }}</flux:subheading>
|
||||
</div>
|
||||
<a href="{{ $this->modulePreviewUrl() }}"
|
||||
target="_blank"
|
||||
class="inline-flex items-center gap-1 text-xs text-blue-600 hover:underline dark:text-blue-400">
|
||||
<flux:icon.arrow-top-right-on-square class="w-3 h-3" />
|
||||
{{ __('Vollbild-Vorschau') }}
|
||||
</a>
|
||||
</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">
|
||||
<iframe
|
||||
wire:key="module-preview-{{ $previewFrameRefreshCounter }}"
|
||||
src="{{ $this->modulePreviewUrl() }}"
|
||||
class="h-full w-full border-0"
|
||||
title="{{ __('Modul-Vorschau') }}"
|
||||
></iframe>
|
||||
</div>
|
||||
</flux:card>
|
||||
|
||||
{{-- Type-specific content sections --}}
|
||||
@if($version->type->value === 'video-display')
|
||||
@include('livewire.admin.cms.partials.version-editor-video', ['items' => $items])
|
||||
|
|
@ -59,8 +84,28 @@
|
|||
@include('livewire.admin.cms.partials.version-editor-offers', ['items' => $items])
|
||||
@endif
|
||||
|
||||
{{-- Module-level metadata --}}
|
||||
<flux:card class="mt-8 mb-6">
|
||||
<form wire:submit.prevent="saveSettings">
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<flux:heading size="lg">{{ __('Meta-Einstellungen für dieses Modul') }}</flux:heading>
|
||||
<flux:subheading>{{ __('Diese Werte gelten für die gesamte Media-Playlist bzw. alle Slides dieses Moduls.') }}</flux:subheading>
|
||||
</div>
|
||||
|
||||
@include('livewire.admin.cms.partials.version-editor-settings', ['context' => 'inline'])
|
||||
|
||||
<div class="flex justify-end">
|
||||
<flux:button type="submit" variant="primary" size="sm">
|
||||
{{ __('Meta-Einstellungen speichern') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</flux:card>
|
||||
|
||||
{{-- Item Modal --}}
|
||||
<flux:modal :open="$showItemModal" wire:model="showItemModal">
|
||||
<flux:modal :open="$showItemModal" wire:model="showItemModal" class="w-full max-w-5xl">
|
||||
<form wire:submit.prevent="saveItem">
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
|
|
@ -194,12 +239,38 @@
|
|||
<flux:checkbox wire:model="slideIsActive" label="Aktiv" />
|
||||
@endif
|
||||
|
||||
<div class="flex justify-end gap-3 pt-4">
|
||||
<div class="space-y-3 border-t border-zinc-200 pt-6 dark:border-zinc-700">
|
||||
<div>
|
||||
<flux:heading size="sm">{{ __('Einzel-Vorschau im Bearbeiten-Dialog') }}</flux:heading>
|
||||
<flux:subheading>{{ __('Zeigt nur den aktuell bearbeiteten Inhalt im Display-Player') }}</flux:subheading>
|
||||
</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">
|
||||
<iframe
|
||||
wire:key="item-modal-module-preview-{{ $previewFrameRefreshCounter }}"
|
||||
src="{{ $this->itemPreviewUrl() }}"
|
||||
class="h-full w-full border-0"
|
||||
title="{{ __('Einzel-Vorschau im Bearbeiten-Dialog') }}"
|
||||
></iframe>
|
||||
</div>
|
||||
|
||||
<a href="{{ $this->itemPreviewUrl() }}"
|
||||
target="_blank"
|
||||
class="mx-auto flex max-w-[320px] items-center justify-center gap-1 text-xs text-blue-600 hover:underline dark:text-blue-400">
|
||||
<flux:icon.arrow-top-right-on-square class="w-3 h-3" />
|
||||
{{ __('Vollbild öffnen') }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap justify-end gap-3 border-t border-zinc-200 pt-4 dark:border-zinc-700">
|
||||
<flux:button type="submit" variant="primary">
|
||||
{{ $itemId ? __('Aktualisieren') : __('Hinzufügen') }}
|
||||
</flux:button>
|
||||
<flux:button type="button" wire:click="closeItemModal" variant="ghost">
|
||||
{{ __('Abbrechen') }}
|
||||
</flux:button>
|
||||
<flux:button type="submit" variant="primary">
|
||||
{{ $itemId ? __('Aktualisieren') : __('Hinzufügen') }}
|
||||
<flux:button type="button" wire:click="closeItemModal">
|
||||
{{ __('Schließen') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -215,31 +286,7 @@
|
|||
<flux:subheading>{{ $version->type->label() }}</flux:subheading>
|
||||
</div>
|
||||
|
||||
@if($version->type->value === 'b2in')
|
||||
<flux:select wire:model="settings.theme" label="Theme">
|
||||
<option value="dark">Dark</option>
|
||||
<option value="light">Light</option>
|
||||
</flux:select>
|
||||
<flux:input wire:model="settings.footer_name" label="Footer Name" placeholder="z.B. Marcel Scheibe" />
|
||||
<flux:input wire:model="settings.footer_url" label="Footer URL" placeholder="z.B. b2in.de" />
|
||||
<flux:select wire:model="settings.transition.type" label="Transition">
|
||||
<option value="crossfade">Crossfade</option>
|
||||
<option value="fade">Fade</option>
|
||||
<option value="slide">Slide</option>
|
||||
</flux:select>
|
||||
<flux:input wire:model="settings.transition.duration_ms" type="number" label="Transition-Dauer (ms)" />
|
||||
<flux:input wire:model="settings.default_image_duration" type="number" label="Standard-Bilddauer (Sek.)" />
|
||||
<flux:checkbox wire:model="settings.display_active" label="Display aktiv" />
|
||||
@elseif($version->type->value === 'offers')
|
||||
<flux:checkbox wire:model="settings.loop" label="Endlosschleife" />
|
||||
<flux:select wire:model="settings.transition.type" label="Transition">
|
||||
<option value="fade">Fade</option>
|
||||
<option value="slide">Slide</option>
|
||||
</flux:select>
|
||||
<flux:input wire:model="settings.transition.duration" type="number" label="Transition-Dauer (ms)" />
|
||||
@elseif($version->type->value === 'video-display')
|
||||
<p class="text-sm text-zinc-500 dark:text-zinc-400">{{ __('Keine speziellen Einstellungen für diesen Typ.') }}</p>
|
||||
@endif
|
||||
@include('livewire.admin.cms.partials.version-editor-settings', ['context' => 'modal'])
|
||||
|
||||
<div class="flex justify-end gap-3 pt-4">
|
||||
<flux:button type="button" wire:click="$set('showSettingsModal', false)" variant="ghost">
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<div>
|
||||
<flux:header class="mb-6">
|
||||
<flux:heading size="xl">{{ __('Display-Versionen') }}</flux:heading>
|
||||
<flux:subheading>{{ __('Erstellen und verwalten Sie Inhalts-Versionen für Ihre Displays') }}</flux:subheading>
|
||||
<flux:heading size="xl">{{ __('Display-Module') }}</flux:heading>
|
||||
<flux:subheading>{{ __('Erstellen und verwalten Sie wiederverwendbare Inhalts-Module für Ihre Displays') }}</flux:subheading>
|
||||
</flux:header>
|
||||
|
||||
@if (session()->has('success'))
|
||||
|
|
@ -13,18 +13,18 @@
|
|||
<flux:card>
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<flux:heading size="lg">{{ __('Versionen') }}</flux:heading>
|
||||
<flux:subheading>{{ __('Jede Version enthält Inhalte eines bestimmten Typs') }}</flux:subheading>
|
||||
<flux:heading size="lg">{{ __('Module') }}</flux:heading>
|
||||
<flux:subheading>{{ __('Jedes Modul enthält Inhalte eines bestimmten Typs') }}</flux:subheading>
|
||||
</div>
|
||||
<flux:button wire:click="openCreateModal" icon="plus" variant="primary">
|
||||
{{ __('Version erstellen') }}
|
||||
{{ __('Modul erstellen') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
|
||||
@if($versions->isEmpty())
|
||||
<div class="text-center py-12 text-zinc-500 dark:text-zinc-400">
|
||||
<flux:icon.rectangle-group class="w-16 h-16 mx-auto mb-4 opacity-50" />
|
||||
<p>{{ __('Noch keine Versionen vorhanden. Erstellen Sie Ihre erste Version!') }}</p>
|
||||
<p>{{ __('Noch keine Module vorhanden. Erstellen Sie Ihr erstes Modul!') }}</p>
|
||||
</div>
|
||||
@else
|
||||
<div class="space-y-3">
|
||||
|
|
@ -37,7 +37,7 @@
|
|||
<flux:badge :color="$version->is_active ? 'green' : 'zinc'" size="sm">
|
||||
{{ $version->is_active ? __('Aktiv') : __('Inaktiv') }}
|
||||
</flux:badge>
|
||||
<a href="{{ route('admin.cms.display-version-edit', $version) }}"
|
||||
<a href="{{ route('admin.cms.display-module-edit', $version) }}"
|
||||
wire:navigate
|
||||
class="font-semibold text-sm hover:underline">
|
||||
{{ $version->name }}
|
||||
|
|
@ -63,7 +63,7 @@
|
|||
:icon="$version->is_active ? 'eye-slash' : 'eye'">
|
||||
</flux:button>
|
||||
|
||||
<flux:button :href="route('admin.cms.display-version-edit', $version)"
|
||||
<flux:button :href="route('admin.cms.display-module-edit', $version)"
|
||||
wire:navigate
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
|
|
@ -71,7 +71,7 @@
|
|||
</flux:button>
|
||||
|
||||
<flux:button wire:click="deleteVersion({{ $version->id }})"
|
||||
wire:confirm="Möchten Sie diese Version wirklich löschen? Alle zugehörigen Inhalte werden ebenfalls gelöscht."
|
||||
wire:confirm="Möchten Sie dieses Modul wirklich löschen? Alle zugehörigen Inhalte werden ebenfalls gelöscht."
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
icon="trash"
|
||||
|
|
@ -89,7 +89,7 @@
|
|||
<form wire:submit.prevent="createVersion">
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<flux:heading size="lg">{{ __('Neue Version erstellen') }}</flux:heading>
|
||||
<flux:heading size="lg">{{ __('Neues Modul erstellen') }}</flux:heading>
|
||||
</div>
|
||||
|
||||
<flux:input wire:model="newName" label="Name" placeholder="z.B. Herbst 2025 Video" />
|
||||
|
|
|
|||
|
|
@ -34,6 +34,16 @@
|
|||
@endif
|
||||
</div>
|
||||
|
||||
<div class="h-16 w-12 shrink-0 overflow-hidden rounded-lg bg-zinc-200 dark:bg-zinc-700">
|
||||
@if(($item->content['media_type'] ?? 'image') === 'image' && !empty($item->content['media_url']))
|
||||
<img src="{{ $item->content['media_url'] }}" alt="" class="h-full w-full object-cover">
|
||||
@else
|
||||
<div class="flex h-full w-full items-center justify-center text-[10px] font-semibold uppercase text-zinc-500">
|
||||
{{ ($item->content['media_type'] ?? 'image') === 'video' ? 'Video' : 'Bild' }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-3 mb-1">
|
||||
<flux:badge :color="$item->is_active ? 'green' : 'zinc'" size="sm">
|
||||
|
|
|
|||
|
|
@ -34,6 +34,13 @@
|
|||
@endif
|
||||
</div>
|
||||
|
||||
<div class="flex h-28 w-20 shrink-0 flex-col rounded-lg border border-zinc-200 bg-white p-1.5 dark:border-zinc-700 dark:bg-zinc-900">
|
||||
<div class="mb-1.5 h-14 rounded bg-zinc-100 bg-cover bg-center dark:bg-zinc-800"
|
||||
@if(!empty($item->content['image_url'])) style="background-image: url('{{ $item->content['image_url'] }}')" @endif></div>
|
||||
<div class="line-clamp-2 text-[10px] font-semibold leading-tight text-zinc-700 dark:text-zinc-200">{{ $item->content['title'] ?? 'Slide' }}</div>
|
||||
<div class="mt-auto truncate text-[9px] text-zinc-500">{{ $item->content['price'] ?? ($item->content['badge_text'] ?? '') }}</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-3 mb-1">
|
||||
<flux:badge :color="$item->is_active ? 'green' : 'zinc'" size="sm">
|
||||
|
|
|
|||
|
|
@ -0,0 +1,61 @@
|
|||
@if($version->type->value === 'b2in')
|
||||
<flux:select wire:model="settings.theme" label="Theme">
|
||||
<option value="dark">Dark</option>
|
||||
<option value="light">Light</option>
|
||||
</flux:select>
|
||||
<div class="space-y-4 rounded-xl border border-zinc-200 p-4 dark:border-zinc-700">
|
||||
<flux:heading size="sm">{{ __('Header') }}</flux:heading>
|
||||
<livewire:admin.cms.display-media-picker
|
||||
:value="null"
|
||||
field="settings.header_logo_url"
|
||||
type="image"
|
||||
label="Header-Logo aus Mediathek"
|
||||
: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" />
|
||||
<flux:input wire:model="settings.header_claim" label="Claim" placeholder="Connecting Design & Property" />
|
||||
</div>
|
||||
<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: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_url" label="Footer Domain" placeholder="z.B. b2in.de" />
|
||||
<flux:input wire:model="settings.qr_url" label="QR-URL (optional)" placeholder="https://b2in.de"
|
||||
description="Leer = QR-Code nutzt die Footer-Domain." />
|
||||
</div>
|
||||
<flux:select wire:model="settings.transition.type" label="Transition">
|
||||
<option value="crossfade">Crossfade</option>
|
||||
<option value="fade">Fade</option>
|
||||
<option value="slide">Slide</option>
|
||||
</flux:select>
|
||||
<flux:input wire:model="settings.transition.duration_ms" type="number" label="Transition-Dauer (ms)" />
|
||||
<flux:input wire:model="settings.default_image_duration" type="number" label="Standard-Bilddauer (Sek.)" />
|
||||
<flux:checkbox wire:model="settings.display_active" label="Display aktiv" />
|
||||
@elseif($version->type->value === 'offers')
|
||||
<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">
|
||||
<option value="fade">Fade</option>
|
||||
<option value="slide">Slide</option>
|
||||
</flux:select>
|
||||
<flux:input wire:model="settings.transition.duration" type="number" label="Transition-Dauer (ms)" />
|
||||
@elseif($version->type->value === 'video-display')
|
||||
<flux:input wire:model="settings.qr_label" label="QR-Label im Footer" placeholder="Website" />
|
||||
@endif
|
||||
|
|
@ -36,6 +36,10 @@
|
|||
@endif
|
||||
</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">
|
||||
Video
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-3 mb-1">
|
||||
<flux:badge :color="$item->is_active ? 'green' : 'zinc'" size="sm">
|
||||
|
|
@ -92,6 +96,11 @@
|
|||
@endif
|
||||
</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="truncate text-zinc-400">{{ $item->content['headline'] ?? 'Footer' }}</div>
|
||||
<div class="truncate font-semibold">{{ $item->content['subline'] ?? '' }}</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-3 mb-1">
|
||||
<flux:badge :color="$item->is_active ? 'green' : 'zinc'" size="sm">
|
||||
|
|
|
|||
|
|
@ -187,7 +187,7 @@
|
|||
<p class="mt-2 text-sm text-muted-foreground">
|
||||
{{ data_get($showUi, 'next_step_text') }}
|
||||
</p>
|
||||
<a href="/contact" class="mt-5 inline-flex w-full justify-center btn-primary-accent">
|
||||
<a href="#projekt-anfrage" class="mt-5 inline-flex w-full justify-center btn-primary-accent">
|
||||
{{ data_get($showUi, 'request_cta') }}
|
||||
</a>
|
||||
</div>
|
||||
|
|
@ -227,13 +227,13 @@
|
|||
{{ data_get($showUi, 'interest_text') }}
|
||||
</p>
|
||||
<div class="mt-8 flex flex-col justify-center gap-3 sm:flex-row">
|
||||
<a href="/contact"
|
||||
<a href="#projekt-anfrage"
|
||||
class="btn-primary-accent px-8 py-4 text-lg">{{ data_get($showUi, 'interest_cta_consult') }}</a>
|
||||
<a href="{{ $overviewUrl }}#projekte"
|
||||
class="btn-secondary-accent px-8 py-4 text-lg">{{ data_get($showUi, 'interest_cta_more') }}</a>
|
||||
</div>
|
||||
|
||||
<div class="mt-10 text-left">
|
||||
<div id="projekt-anfrage" class="mt-10 scroll-mt-24 text-left">
|
||||
<livewire:web.components.sections.immobilien-contact-form :projectSlug="$project['slug'] ?? ''" :projectTitle="$project['title'] ?? ''"
|
||||
:showInterest="false" :submitLabel="data_get($ui, 'modal.request_submit', 'Anfrage absenden')"
|
||||
wire:key="azizi-project-detail-contact-form-{{ $project['slug'] ?? 'project' }}" />
|
||||
|
|
|
|||
|
|
@ -295,6 +295,8 @@
|
|||
<nav x-show="!selectedProject" x-cloak x-transition.opacity x-data="{
|
||||
open: false,
|
||||
active: '',
|
||||
navTop: 12,
|
||||
panelTop: 64,
|
||||
sections: @js($sidebarSections),
|
||||
update() {
|
||||
const offset = window.innerHeight * 0.35
|
||||
|
|
@ -307,6 +309,18 @@
|
|||
}
|
||||
this.active = current
|
||||
},
|
||||
updatePosition() {
|
||||
const header = document.getElementById('header')
|
||||
if (!header) {
|
||||
this.navTop = 12
|
||||
this.panelTop = 64
|
||||
return
|
||||
}
|
||||
|
||||
const rect = header.getBoundingClientRect()
|
||||
this.navTop = Math.max(12, rect.top + 12)
|
||||
this.panelTop = Math.max(64, rect.bottom + 8)
|
||||
},
|
||||
scrollToSection(id) {
|
||||
const el = document.getElementById(id)
|
||||
if (!el) {
|
||||
|
|
@ -319,12 +333,15 @@
|
|||
return this.sections.find(section => section.id === this.active)?.label || this.sections[0]?.label || ''
|
||||
},
|
||||
}" x-init="update();
|
||||
updatePosition();
|
||||
window.addEventListener('scroll', () => update(), { passive: true });
|
||||
window.addEventListener('resize', () => update(), { passive: true });"
|
||||
window.addEventListener('scroll', () => updatePosition(), { passive: true });
|
||||
window.addEventListener('resize', () => { update(); updatePosition(); }, { passive: true });"
|
||||
@keydown.escape.window="open = false" aria-label="{{ data_get($ui, 'sidebar.toggle_open') }}"
|
||||
class="fixed right-14 top-3 z-[60] lg:hidden">
|
||||
<div x-show="open" x-transition.origin.top.right
|
||||
class="fixed inset-x-3 top-16 rounded-2xl border border-border bg-background p-3 shadow-2xl shadow-zinc-950/25">
|
||||
class="fixed right-[3.25rem] z-[60] md:hidden" :style="`top: ${navTop}px`">
|
||||
<div x-show="open" x-cloak x-transition.origin.top.right @click.outside="open = false"
|
||||
class="fixed inset-x-3 rounded-2xl border border-border bg-background p-3 shadow-2xl shadow-zinc-950/25"
|
||||
:style="`top: ${panelTop}px`">
|
||||
<div class="mb-3 flex items-center justify-between gap-3 border-b border-border pb-3">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.16em] text-muted-foreground">
|
||||
{{ data_get($ui, 'sidebar.mobile_label', 'Abschnitt') }}
|
||||
|
|
|
|||
|
|
@ -66,8 +66,12 @@ Route::middleware(['auth', 'partner.setup'])->group(function () {
|
|||
// Display CMS
|
||||
Volt::route('admin/cms/display-dashboard', 'admin.cms.display-dashboard')->name('admin.cms.display-dashboard');
|
||||
Volt::route('admin/cms/display-media', 'admin.cms.display-media-library')->name('admin.cms.display-media');
|
||||
Route::get('admin/cms/display-versions', \App\Livewire\Admin\Cms\DisplayVersionList::class)->name('admin.cms.display-versions');
|
||||
Route::get('admin/cms/display-versions/{displayVersion}/edit', \App\Livewire\Admin\Cms\DisplayVersionEditor::class)->name('admin.cms.display-version-edit');
|
||||
Route::redirect('admin/cms/display-versions', 'admin/cms/display-modules', 301)->name('admin.cms.display-versions');
|
||||
Route::get('admin/cms/display-versions/{displayVersion}/edit', function (\App\Models\DisplayVersion $displayVersion) {
|
||||
return redirect()->route('admin.cms.display-module-edit', $displayVersion, 301);
|
||||
})->name('admin.cms.display-version-edit');
|
||||
Route::get('admin/cms/display-modules', \App\Livewire\Admin\Cms\DisplayVersionList::class)->name('admin.cms.display-modules');
|
||||
Route::get('admin/cms/display-modules/{displayVersion}/edit', \App\Livewire\Admin\Cms\DisplayVersionEditor::class)->name('admin.cms.display-module-edit');
|
||||
Route::get('admin/cms/displays', \App\Livewire\Admin\Cms\DisplayList::class)->name('admin.cms.displays');
|
||||
|
||||
// Product Routes
|
||||
|
|
|
|||
|
|
@ -47,6 +47,12 @@ Route::domain($domainPortal)->group(function () {
|
|||
// Display Version API (per physical display)
|
||||
Route::get('/api/display/{display}/config', [\App\Http\Controllers\Api\DisplayVersionApiController::class, 'config']);
|
||||
Route::get('/api/display/{display}/check', [\App\Http\Controllers\Api\DisplayVersionApiController::class, 'check']);
|
||||
Route::get('/api/display/preview/{token}', [\App\Http\Controllers\Api\DisplayPreviewController::class, 'config']);
|
||||
Route::get('/api/display/module/{module}/item/{item}/preview', [\App\Http\Controllers\Api\ModulePreviewController::class, 'configItem']);
|
||||
Route::get('/api/display/module/{module}/preview', [\App\Http\Controllers\Api\ModulePreviewController::class, 'config']);
|
||||
Route::get('/preview/module/{module}/item/{item}', [\App\Http\Controllers\Api\ModulePreviewController::class, 'showItem']);
|
||||
Route::get('/preview/module/{module}', [\App\Http\Controllers\Api\ModulePreviewController::class, 'show']);
|
||||
Route::get('/preview/{token}', [\App\Http\Controllers\Api\DisplayPreviewController::class, 'show']);
|
||||
|
||||
// FluxUI Asset-Routen explizit für Portal-Domain registrieren
|
||||
// (Notwendig weil Route-Cache die globalen Flux-Routen nicht für alle Domains enthält)
|
||||
|
|
@ -123,3 +129,9 @@ Route::get('/api/cabinet-tablet/check', [\App\Http\Controllers\Api\CabinetTablet
|
|||
// Fallback: Display Version API
|
||||
Route::get('/api/display/{display}/config', [\App\Http\Controllers\Api\DisplayVersionApiController::class, 'config']);
|
||||
Route::get('/api/display/{display}/check', [\App\Http\Controllers\Api\DisplayVersionApiController::class, 'check']);
|
||||
Route::get('/api/display/preview/{token}', [\App\Http\Controllers\Api\DisplayPreviewController::class, 'config']);
|
||||
Route::get('/api/display/module/{module}/item/{item}/preview', [\App\Http\Controllers\Api\ModulePreviewController::class, 'configItem']);
|
||||
Route::get('/api/display/module/{module}/preview', [\App\Http\Controllers\Api\ModulePreviewController::class, 'config']);
|
||||
Route::get('/preview/module/{module}/item/{item}', [\App\Http\Controllers\Api\ModulePreviewController::class, 'showItem']);
|
||||
Route::get('/preview/module/{module}', [\App\Http\Controllers\Api\ModulePreviewController::class, 'show']);
|
||||
Route::get('/preview/{token}', [\App\Http\Controllers\Api\DisplayPreviewController::class, 'show']);
|
||||
|
|
|
|||
|
|
@ -2,16 +2,39 @@
|
|||
|
||||
use App\Livewire\Admin\Cms\DisplayList;
|
||||
use App\Models\Display;
|
||||
use App\Models\DisplayPlaylist;
|
||||
use App\Models\DisplayPlaylistItem;
|
||||
use App\Models\DisplayVersion;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Livewire\Livewire;
|
||||
|
||||
beforeEach(function () {
|
||||
$portalDomain = config('domains.domain_portal');
|
||||
Livewire::withoutLazyLoading();
|
||||
url()->forceRootUrl('https://'.$portalDomain);
|
||||
URL::forceRootUrl('https://'.$portalDomain);
|
||||
});
|
||||
|
||||
function createDisplayListPlaylist(Display $display, string $status, array $versionIds): DisplayPlaylist
|
||||
{
|
||||
$playlist = DisplayPlaylist::factory()
|
||||
->state(['status' => $status])
|
||||
->create([
|
||||
'display_id' => $display->id,
|
||||
'published_at' => $status === DisplayPlaylist::STATUS_PUBLISHED ? now() : null,
|
||||
]);
|
||||
|
||||
foreach (array_values($versionIds) as $sortOrder => $versionId) {
|
||||
DisplayPlaylistItem::factory()->create([
|
||||
'display_playlist_id' => $playlist->id,
|
||||
'display_version_id' => $versionId,
|
||||
'sort_order' => $sortOrder,
|
||||
]);
|
||||
}
|
||||
|
||||
return $playlist;
|
||||
}
|
||||
|
||||
test('display list requires authentication', function () {
|
||||
$response = $this->get(route('admin.cms.displays'));
|
||||
|
||||
|
|
@ -59,6 +82,7 @@ test('can assign versions to a display', function () {
|
|||
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]);
|
||||
});
|
||||
|
||||
test('can reorder versions in playlist', function () {
|
||||
|
|
@ -78,6 +102,7 @@ test('can reorder versions in playlist', function () {
|
|||
$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]);
|
||||
});
|
||||
|
||||
test('can remove version from playlist', function () {
|
||||
|
|
@ -99,6 +124,7 @@ test('can remove version from playlist', function () {
|
|||
$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]);
|
||||
});
|
||||
|
||||
test('can delete a display', function () {
|
||||
|
|
@ -146,3 +172,191 @@ test('does not add duplicate version to playlist', function () {
|
|||
|
||||
expect($component->get('selectedVersionIds'))->toHaveCount(1);
|
||||
});
|
||||
|
||||
test('plus button adds the first available module when select has no explicit value', function () {
|
||||
$user = User::factory()->create();
|
||||
$firstVersion = DisplayVersion::factory()->create(['name' => 'Angebote Schauraum']);
|
||||
DisplayVersion::factory()->create(['name' => 'Schaufenster Video']);
|
||||
|
||||
$component = Livewire::actingAs($user)
|
||||
->test(DisplayList::class)
|
||||
->call('openModal')
|
||||
->call('addVersion');
|
||||
|
||||
expect($component->get('selectedVersionIds'))->toBe([$firstVersion->id]);
|
||||
});
|
||||
|
||||
test('can create a draft playlist from live modules', function () {
|
||||
$user = User::factory()->create();
|
||||
$version1 = DisplayVersion::factory()->create();
|
||||
$version2 = DisplayVersion::factory()->create();
|
||||
$display = Display::factory()->create(['preview_token' => null]);
|
||||
createDisplayListPlaylist($display, DisplayPlaylist::STATUS_PUBLISHED, [$version1->id, $version2->id]);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(DisplayList::class)
|
||||
->call('createDraft', $display->id);
|
||||
|
||||
$display->refresh();
|
||||
|
||||
expect($display->preview_token)->not->toBeNull();
|
||||
expect($display->draftPlaylist)->not->toBeNull();
|
||||
expect($display->draftPlaylist->modules->pluck('id')->all())->toBe([$version1->id, $version2->id]);
|
||||
});
|
||||
|
||||
test('can discard a draft playlist', function () {
|
||||
$user = User::factory()->create();
|
||||
$version = DisplayVersion::factory()->create();
|
||||
$display = Display::factory()->create();
|
||||
createDisplayListPlaylist($display, DisplayPlaylist::STATUS_DRAFT, [$version->id]);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(DisplayList::class)
|
||||
->call('discardDraft', $display->id);
|
||||
|
||||
expect($display->fresh()->draftPlaylist)->toBeNull();
|
||||
});
|
||||
|
||||
test('can publish a draft playlist over the live playlist', function () {
|
||||
$user = User::factory()->create();
|
||||
$liveVersion = DisplayVersion::factory()->create(['name' => 'Live Modul']);
|
||||
$draftVersion = DisplayVersion::factory()->create(['name' => 'Draft Modul']);
|
||||
$display = Display::factory()->create();
|
||||
createDisplayListPlaylist($display, DisplayPlaylist::STATUS_PUBLISHED, [$liveVersion->id]);
|
||||
createDisplayListPlaylist($display, DisplayPlaylist::STATUS_DRAFT, [$draftVersion->id]);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(DisplayList::class)
|
||||
->call('publishDraft', $display->id);
|
||||
|
||||
$display->refresh();
|
||||
|
||||
expect($display->draftPlaylist)->toBeNull();
|
||||
expect($display->livePlaylist->modules->pluck('id')->all())->toBe([$draftVersion->id]);
|
||||
expect($display->versions->pluck('id')->all())->toBe([$draftVersion->id]);
|
||||
});
|
||||
|
||||
test('can edit live playlist without changing draft playlist', function () {
|
||||
$user = User::factory()->create();
|
||||
$liveVersion = DisplayVersion::factory()->create();
|
||||
$draftVersion = DisplayVersion::factory()->create();
|
||||
$newLiveVersion = DisplayVersion::factory()->create();
|
||||
$display = Display::factory()->create();
|
||||
createDisplayListPlaylist($display, DisplayPlaylist::STATUS_PUBLISHED, [$liveVersion->id]);
|
||||
createDisplayListPlaylist($display, DisplayPlaylist::STATUS_DRAFT, [$draftVersion->id]);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(DisplayList::class)
|
||||
->call('openModal', $display->id, DisplayPlaylist::STATUS_PUBLISHED)
|
||||
->call('addVersion', $newLiveVersion->id)
|
||||
->call('save');
|
||||
|
||||
$display->refresh();
|
||||
|
||||
expect($display->livePlaylist->modules->pluck('id')->all())->toBe([$liveVersion->id, $newLiveVersion->id]);
|
||||
expect($display->draftPlaylist->modules->pluck('id')->all())->toBe([$draftVersion->id]);
|
||||
});
|
||||
|
||||
test('can edit draft playlist without changing live playlist', function () {
|
||||
$user = User::factory()->create();
|
||||
$liveVersion = DisplayVersion::factory()->create();
|
||||
$draftVersion = DisplayVersion::factory()->create();
|
||||
$newDraftVersion = DisplayVersion::factory()->create();
|
||||
$display = Display::factory()->create(['preview_token' => null]);
|
||||
createDisplayListPlaylist($display, DisplayPlaylist::STATUS_PUBLISHED, [$liveVersion->id]);
|
||||
createDisplayListPlaylist($display, DisplayPlaylist::STATUS_DRAFT, [$draftVersion->id]);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(DisplayList::class)
|
||||
->call('openModal', $display->id, DisplayPlaylist::STATUS_DRAFT)
|
||||
->call('addVersion', $newDraftVersion->id)
|
||||
->call('save');
|
||||
|
||||
$display->refresh();
|
||||
|
||||
expect($display->preview_token)->not->toBeNull();
|
||||
expect($display->livePlaylist->modules->pluck('id')->all())->toBe([$liveVersion->id]);
|
||||
expect($display->draftPlaylist->modules->pluck('id')->all())->toBe([$draftVersion->id, $newDraftVersion->id]);
|
||||
expect($display->versions->pluck('id')->all())->toBe([]);
|
||||
});
|
||||
|
||||
test('draft editor renders iframe preview url', function () {
|
||||
$user = User::factory()->create();
|
||||
$draftVersion = DisplayVersion::factory()->create();
|
||||
$display = Display::factory()->create(['preview_token' => null]);
|
||||
createDisplayListPlaylist($display, DisplayPlaylist::STATUS_DRAFT, [$draftVersion->id]);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(DisplayList::class)
|
||||
->call('openModal', $display->id, DisplayPlaylist::STATUS_DRAFT)
|
||||
->assertSee('Live-Vorschau')
|
||||
->assertSee('/preview/'.$display->fresh()->preview_token, false)
|
||||
->assertSeeHtml('<iframe');
|
||||
});
|
||||
|
||||
test('draft editor persists module changes immediately for preview reloads', function () {
|
||||
$user = User::factory()->create();
|
||||
$draftVersion = DisplayVersion::factory()->create();
|
||||
$newDraftVersion = DisplayVersion::factory()->create();
|
||||
$display = Display::factory()->create();
|
||||
createDisplayListPlaylist($display, DisplayPlaylist::STATUS_DRAFT, [$draftVersion->id]);
|
||||
|
||||
$component = Livewire::actingAs($user)
|
||||
->test(DisplayList::class)
|
||||
->call('openModal', $display->id, DisplayPlaylist::STATUS_DRAFT);
|
||||
|
||||
$initialRefreshCounter = $component->get('previewFrameRefreshCounter');
|
||||
|
||||
$component->call('addVersion', $newDraftVersion->id);
|
||||
|
||||
$display->refresh();
|
||||
|
||||
expect($display->draftPlaylist->modules->pluck('id')->all())->toBe([$draftVersion->id, $newDraftVersion->id]);
|
||||
expect($component->get('previewFrameRefreshCounter'))->toBeGreaterThan($initialRefreshCounter);
|
||||
});
|
||||
|
||||
test('module select only shows modules that can still be added', function () {
|
||||
$user = User::factory()->create();
|
||||
$selectedVersion = DisplayVersion::factory()->create(['name' => 'Schon gewählt']);
|
||||
$availableVersion = DisplayVersion::factory()->create(['name' => 'Noch verfügbar']);
|
||||
$display = Display::factory()->create();
|
||||
createDisplayListPlaylist($display, DisplayPlaylist::STATUS_PUBLISHED, [$selectedVersion->id]);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(DisplayList::class)
|
||||
->call('openModal', $display->id, DisplayPlaylist::STATUS_PUBLISHED)
|
||||
->assertDontSeeHtml('<option value="'.$selectedVersion->id.'">Schon gewählt')
|
||||
->assertSeeHtml('<option value="'.$availableVersion->id.'">Noch verfügbar');
|
||||
});
|
||||
|
||||
test('module select is replaced by a hint when all modules are already selected', function () {
|
||||
$user = User::factory()->create();
|
||||
$selectedVersion = DisplayVersion::factory()->create(['name' => 'Einziges Modul']);
|
||||
$display = Display::factory()->create();
|
||||
createDisplayListPlaylist($display, DisplayPlaylist::STATUS_PUBLISHED, [$selectedVersion->id]);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(DisplayList::class)
|
||||
->call('openModal', $display->id, DisplayPlaylist::STATUS_PUBLISHED)
|
||||
->assertSee('Alle verfügbaren Module sind bereits hinzugefügt.')
|
||||
->assertDontSeeHtml('<option value="'.$selectedVersion->id.'">Einziges Modul');
|
||||
});
|
||||
|
||||
test('renders live and draft playlist columns', function () {
|
||||
$user = User::factory()->create();
|
||||
$liveVersion = DisplayVersion::factory()->create(['name' => 'Live Modul']);
|
||||
$draftVersion = DisplayVersion::factory()->create(['name' => 'Draft Modul']);
|
||||
$display = Display::factory()->test()->create(['name' => 'Test-Display']);
|
||||
createDisplayListPlaylist($display, DisplayPlaylist::STATUS_PUBLISHED, [$liveVersion->id]);
|
||||
createDisplayListPlaylist($display, DisplayPlaylist::STATUS_DRAFT, [$draftVersion->id]);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(DisplayList::class)
|
||||
->assertSee('Live Modul')
|
||||
->assertSee('Draft Modul')
|
||||
->assertSee('Live bearbeiten')
|
||||
->assertSee('Live-URL zum Kopieren')
|
||||
->assertSee(url('/_cabinet/display/index.html').'?id='.$display->id, false)
|
||||
->assertSee('Entwurf bearbeiten')
|
||||
->assertSee('Test-Display');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -142,6 +142,41 @@ it('stores a video upload', function () {
|
|||
->and($media->mime_type)->toBe('video/mp4');
|
||||
});
|
||||
|
||||
it('stores an svg upload as image media', function () {
|
||||
$service = app(DisplayMediaService::class);
|
||||
$file = UploadedFile::fake()->createWithContent(
|
||||
'logo.svg',
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><rect width="10" height="10"/></svg>'
|
||||
);
|
||||
|
||||
$media = $service->storeUpload($file);
|
||||
|
||||
expect($media->filename)->toBe('logo.svg')
|
||||
->and($media->type)->toBe('image')
|
||||
->and($media->mime_type)->toBe('image/svg+xml');
|
||||
|
||||
expect(Storage::disk('public')->exists($media->path))->toBeTrue();
|
||||
});
|
||||
|
||||
it('accepts svg uploads in the display media library', function () {
|
||||
$file = UploadedFile::fake()->createWithContent(
|
||||
'brand.svg',
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><circle cx="5" cy="5" r="5"/></svg>'
|
||||
);
|
||||
|
||||
Volt::test('admin.cms.display-media-library')
|
||||
->set('uploads', [$file])
|
||||
->call('handleUploads')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$media = DisplayMedia::query()->first();
|
||||
|
||||
expect($media)->not->toBeNull()
|
||||
->and($media->filename)->toBe('brand.svg')
|
||||
->and($media->type)->toBe('image')
|
||||
->and($media->mime_type)->toBe('image/svg+xml');
|
||||
});
|
||||
|
||||
it('accepts display media videos up to 200 mb', function () {
|
||||
$file = UploadedFile::fake()->create('showroom.mp4', 204800, 'video/mp4');
|
||||
|
||||
|
|
|
|||
152
tests/Feature/DisplayPlaylistMigrationTest.php
Normal file
152
tests/Feature/DisplayPlaylistMigrationTest.php
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Display;
|
||||
use App\Models\DisplayPlaylist;
|
||||
use App\Models\DisplayPlaylistItem;
|
||||
use App\Models\DisplayVersion;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
it('creates the display_playlists table with unique status per display', function () {
|
||||
expect(Schema::hasTable('display_playlists'))->toBeTrue();
|
||||
expect(Schema::hasColumns('display_playlists', [
|
||||
'id', 'display_id', 'status', 'published_at', 'published_by', 'notes',
|
||||
'created_at', 'updated_at',
|
||||
]))->toBeTrue();
|
||||
});
|
||||
|
||||
it('creates the display_playlist_items table', function () {
|
||||
expect(Schema::hasTable('display_playlist_items'))->toBeTrue();
|
||||
expect(Schema::hasColumns('display_playlist_items', [
|
||||
'id', 'display_playlist_id', 'display_version_id', 'sort_order',
|
||||
'created_at', 'updated_at',
|
||||
]))->toBeTrue();
|
||||
});
|
||||
|
||||
it('adds is_test and preview_token to displays', function () {
|
||||
expect(Schema::hasColumns('displays', ['is_test', 'preview_token']))->toBeTrue();
|
||||
});
|
||||
|
||||
it('migrates existing pivot entries into a published playlist with same ordering', function () {
|
||||
$display = Display::factory()->create();
|
||||
$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 () {
|
||||
$display = Display::factory()->create();
|
||||
|
||||
$live = DisplayPlaylist::factory()->published()->create(['display_id' => $display->id]);
|
||||
$draft = DisplayPlaylist::factory()->draft()->create(['display_id' => $display->id]);
|
||||
|
||||
expect($display->livePlaylist->is($live))->toBeTrue();
|
||||
expect($display->draftPlaylist->is($draft))->toBeTrue();
|
||||
});
|
||||
|
||||
it('prevents two playlists with the same status for one display', function () {
|
||||
$display = Display::factory()->create();
|
||||
DisplayPlaylist::factory()->published()->create(['display_id' => $display->id]);
|
||||
|
||||
DisplayPlaylist::factory()->published()->create(['display_id' => $display->id]);
|
||||
})->throws(\Illuminate\Database\QueryException::class);
|
||||
|
||||
it('orders playlist items by sort_order on the modules relation', function () {
|
||||
$display = Display::factory()->create();
|
||||
$playlist = DisplayPlaylist::factory()->published()->create(['display_id' => $display->id]);
|
||||
$modules = DisplayVersion::factory()->count(3)->create();
|
||||
|
||||
DisplayPlaylistItem::factory()->create([
|
||||
'display_playlist_id' => $playlist->id,
|
||||
'display_version_id' => $modules[2]->id,
|
||||
'sort_order' => 2,
|
||||
]);
|
||||
DisplayPlaylistItem::factory()->create([
|
||||
'display_playlist_id' => $playlist->id,
|
||||
'display_version_id' => $modules[0]->id,
|
||||
'sort_order' => 0,
|
||||
]);
|
||||
DisplayPlaylistItem::factory()->create([
|
||||
'display_playlist_id' => $playlist->id,
|
||||
'display_version_id' => $modules[1]->id,
|
||||
'sort_order' => 1,
|
||||
]);
|
||||
|
||||
expect($playlist->modules->pluck('id')->all())->toBe([
|
||||
$modules[0]->id,
|
||||
$modules[1]->id,
|
||||
$modules[2]->id,
|
||||
]);
|
||||
});
|
||||
|
||||
it('generates a preview token on demand and persists it', function () {
|
||||
$display = Display::factory()->create(['preview_token' => null]);
|
||||
|
||||
$token = $display->ensurePreviewToken();
|
||||
|
||||
expect($token)->toBeString();
|
||||
expect(strlen($token))->toBeGreaterThanOrEqual(40);
|
||||
expect($display->fresh()->preview_token)->toBe($token);
|
||||
|
||||
expect($display->ensurePreviewToken())->toBe($token);
|
||||
});
|
||||
|
||||
it('marks a display as test display via factory state', function () {
|
||||
$display = Display::factory()->test()->create();
|
||||
|
||||
expect($display->is_test)->toBeTrue();
|
||||
expect($display->name)->toBe('Test-Display');
|
||||
});
|
||||
|
|
@ -1,14 +1,51 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Display;
|
||||
use App\Models\DisplayPlaylist;
|
||||
use App\Models\DisplayPlaylistItem;
|
||||
use App\Models\DisplayVersion;
|
||||
use App\Models\DisplayVersionItem;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
|
||||
beforeEach(function () {
|
||||
$portalDomain = config('domains.domain_portal');
|
||||
url()->forceRootUrl('https://'.$portalDomain);
|
||||
URL::forceRootUrl('https://'.$portalDomain);
|
||||
});
|
||||
|
||||
function publishDisplayModules(Display $display, array $versionIds): DisplayPlaylist
|
||||
{
|
||||
$playlist = DisplayPlaylist::factory()->published()->create([
|
||||
'display_id' => $display->id,
|
||||
]);
|
||||
|
||||
foreach (array_values($versionIds) as $sortOrder => $versionId) {
|
||||
DisplayPlaylistItem::factory()->create([
|
||||
'display_playlist_id' => $playlist->id,
|
||||
'display_version_id' => $versionId,
|
||||
'sort_order' => $sortOrder,
|
||||
]);
|
||||
}
|
||||
|
||||
return $playlist;
|
||||
}
|
||||
|
||||
function draftDisplayModules(Display $display, array $versionIds): DisplayPlaylist
|
||||
{
|
||||
$playlist = DisplayPlaylist::factory()->draft()->create([
|
||||
'display_id' => $display->id,
|
||||
]);
|
||||
|
||||
foreach (array_values($versionIds) as $sortOrder => $versionId) {
|
||||
DisplayPlaylistItem::factory()->create([
|
||||
'display_playlist_id' => $playlist->id,
|
||||
'display_version_id' => $versionId,
|
||||
'sort_order' => $sortOrder,
|
||||
]);
|
||||
}
|
||||
|
||||
return $playlist;
|
||||
}
|
||||
|
||||
test('returns playlist with video-display config', function () {
|
||||
$version = DisplayVersion::factory()->create(['type' => 'video-display']);
|
||||
DisplayVersionItem::factory()->create([
|
||||
|
|
@ -22,7 +59,7 @@ test('returns playlist with video-display config', function () {
|
|||
'content' => ['headline' => 'Hello', 'subline' => 'World', 'url' => null],
|
||||
]);
|
||||
$display = Display::factory()->create();
|
||||
$display->versions()->attach($version->id, ['sort_order' => 0]);
|
||||
publishDisplayModules($display, [$version->id]);
|
||||
|
||||
$response = $this->getJson("/api/display/{$display->id}/config");
|
||||
|
||||
|
|
@ -33,6 +70,26 @@ test('returns playlist with video-display config', function () {
|
|||
$response->assertJsonPath('playlist.0.footerContent.0.headline', 'Hello');
|
||||
});
|
||||
|
||||
test('returns display media video urls without legacy asset prefix', function () {
|
||||
$version = DisplayVersion::factory()->create(['type' => 'video-display']);
|
||||
DisplayVersionItem::factory()->create([
|
||||
'display_version_id' => $version->id,
|
||||
'item_type' => 'video',
|
||||
'content' => [
|
||||
'filename' => '/storage/display-media/2026/05/video.mp4',
|
||||
'title' => 'Uploaded Video',
|
||||
'position' => 25,
|
||||
],
|
||||
]);
|
||||
$display = Display::factory()->create();
|
||||
publishDisplayModules($display, [$version->id]);
|
||||
|
||||
$response = $this->getJson("/api/display/{$display->id}/config");
|
||||
|
||||
$response->assertSuccessful();
|
||||
$response->assertJsonPath('playlist.0.videoPlaylist.0.src', '/storage/display-media/2026/05/video.mp4');
|
||||
});
|
||||
|
||||
test('returns playlist with b2in config', function () {
|
||||
$version = DisplayVersion::factory()->create([
|
||||
'type' => 'b2in',
|
||||
|
|
@ -51,7 +108,7 @@ test('returns playlist with b2in config', function () {
|
|||
],
|
||||
]);
|
||||
$display = Display::factory()->create();
|
||||
$display->versions()->attach($version->id, ['sort_order' => 0]);
|
||||
publishDisplayModules($display, [$version->id]);
|
||||
|
||||
$response = $this->getJson("/api/display/{$display->id}/config");
|
||||
|
||||
|
|
@ -90,7 +147,7 @@ test('returns playlist with offers config', function () {
|
|||
],
|
||||
]);
|
||||
$display = Display::factory()->create();
|
||||
$display->versions()->attach($version->id, ['sort_order' => 0]);
|
||||
publishDisplayModules($display, [$version->id]);
|
||||
|
||||
$response = $this->getJson("/api/display/{$display->id}/config");
|
||||
|
||||
|
|
@ -118,10 +175,7 @@ test('returns playlist with multiple versions in order', function () {
|
|||
]);
|
||||
|
||||
$display = Display::factory()->create();
|
||||
$display->versions()->attach([
|
||||
$videoVersion->id => ['sort_order' => 0],
|
||||
$b2inVersion->id => ['sort_order' => 1],
|
||||
]);
|
||||
publishDisplayModules($display, [$videoVersion->id, $b2inVersion->id]);
|
||||
|
||||
$response = $this->getJson("/api/display/{$display->id}/config");
|
||||
|
||||
|
|
@ -134,7 +188,7 @@ test('returns playlist with multiple versions in order', function () {
|
|||
test('returns 404 for inactive display', function () {
|
||||
$version = DisplayVersion::factory()->create();
|
||||
$display = Display::factory()->create(['is_active' => false]);
|
||||
$display->versions()->attach($version->id, ['sort_order' => 0]);
|
||||
publishDisplayModules($display, [$version->id]);
|
||||
|
||||
$response = $this->getJson("/api/display/{$display->id}/config");
|
||||
|
||||
|
|
@ -152,7 +206,7 @@ test('returns 404 for display without versions', function () {
|
|||
test('check endpoint returns only updated_at', function () {
|
||||
$version = DisplayVersion::factory()->create();
|
||||
$display = Display::factory()->create();
|
||||
$display->versions()->attach($version->id, ['sort_order' => 0]);
|
||||
publishDisplayModules($display, [$version->id]);
|
||||
|
||||
$response = $this->getJson("/api/display/{$display->id}/check");
|
||||
|
||||
|
|
@ -160,6 +214,206 @@ test('check endpoint returns only updated_at', function () {
|
|||
$response->assertJsonStructure(['updated_at']);
|
||||
});
|
||||
|
||||
test('display config ignores legacy pivot and reads published playlist', function () {
|
||||
$legacyVersion = DisplayVersion::factory()->create(['type' => 'video-display', 'name' => 'Legacy']);
|
||||
DisplayVersionItem::factory()->create([
|
||||
'display_version_id' => $legacyVersion->id,
|
||||
'item_type' => 'video',
|
||||
'content' => ['filename' => 'legacy.mp4', 'title' => 'Legacy', 'position' => 25],
|
||||
]);
|
||||
|
||||
$publishedVersion = DisplayVersion::factory()->create(['type' => 'video-display', 'name' => 'Published']);
|
||||
DisplayVersionItem::factory()->create([
|
||||
'display_version_id' => $publishedVersion->id,
|
||||
'item_type' => 'video',
|
||||
'content' => ['filename' => 'published.mp4', 'title' => 'Published', 'position' => 25],
|
||||
]);
|
||||
|
||||
$display = Display::factory()->create();
|
||||
$display->versions()->attach($legacyVersion->id, ['sort_order' => 0]);
|
||||
publishDisplayModules($display, [$publishedVersion->id]);
|
||||
|
||||
$response = $this->getJson("/api/display/{$display->id}/config");
|
||||
|
||||
$response->assertSuccessful();
|
||||
$response->assertJsonPath('playlist.0.version_name', 'Published');
|
||||
$response->assertJsonPath('playlist.0.videoPlaylist.0.src', 'assets/published.mp4');
|
||||
});
|
||||
|
||||
test('draft preview returns playlist by preview token', function () {
|
||||
$liveVersion = DisplayVersion::factory()->create(['type' => 'video-display', 'name' => 'Live']);
|
||||
DisplayVersionItem::factory()->create([
|
||||
'display_version_id' => $liveVersion->id,
|
||||
'item_type' => 'video',
|
||||
'content' => ['filename' => 'live.mp4', 'title' => 'Live', 'position' => 25],
|
||||
]);
|
||||
|
||||
$draftVersion = DisplayVersion::factory()->create(['type' => 'video-display', 'name' => 'Draft']);
|
||||
DisplayVersionItem::factory()->create([
|
||||
'display_version_id' => $draftVersion->id,
|
||||
'item_type' => 'video',
|
||||
'content' => ['filename' => 'draft.mp4', 'title' => 'Draft', 'position' => 25],
|
||||
]);
|
||||
|
||||
$display = Display::factory()->create(['preview_token' => 'preview-token-123']);
|
||||
publishDisplayModules($display, [$liveVersion->id]);
|
||||
draftDisplayModules($display, [$draftVersion->id]);
|
||||
|
||||
$response = $this->getJson('/api/display/preview/preview-token-123');
|
||||
|
||||
$response->assertSuccessful();
|
||||
$response->assertJsonPath('playlist.0.version_name', 'Draft');
|
||||
$response->assertJsonPath('playlist.0.videoPlaylist.0.src', 'assets/draft.mp4');
|
||||
});
|
||||
|
||||
test('draft preview requires configured draft playlist', function () {
|
||||
$display = Display::factory()->create(['preview_token' => 'empty-preview-token']);
|
||||
|
||||
$response = $this->getJson('/api/display/preview/empty-preview-token');
|
||||
|
||||
$response->assertNotFound();
|
||||
});
|
||||
|
||||
test('module preview returns a single module config', function () {
|
||||
$version = DisplayVersion::factory()->create([
|
||||
'type' => 'b2in',
|
||||
'name' => 'Preview Module',
|
||||
'settings' => ['theme' => 'light'],
|
||||
]);
|
||||
DisplayVersionItem::factory()->create([
|
||||
'display_version_id' => $version->id,
|
||||
'item_type' => 'media',
|
||||
'content' => [
|
||||
'category' => 'moebel',
|
||||
'media_type' => 'image',
|
||||
'media_url' => '../assets/module.jpg',
|
||||
'headline' => 'Modul',
|
||||
'subline' => 'Vorschau',
|
||||
'duration_seconds' => 8,
|
||||
],
|
||||
]);
|
||||
|
||||
$response = $this->getJson("/api/display/module/{$version->id}/preview");
|
||||
|
||||
$response->assertSuccessful();
|
||||
$response->assertJsonCount(1, 'playlist');
|
||||
$response->assertJsonPath('playlist.0.version_name', 'Preview Module');
|
||||
$response->assertJsonPath('playlist.0.items.0.category', 'moebel');
|
||||
});
|
||||
|
||||
test('module preview exposes configurable player chrome settings', function () {
|
||||
$version = DisplayVersion::factory()->create([
|
||||
'type' => 'offers',
|
||||
'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([
|
||||
'display_version_id' => $version->id,
|
||||
'item_type' => 'slide',
|
||||
'content' => [
|
||||
'type' => 'intro',
|
||||
'title' => 'Intro',
|
||||
'image_url' => '../assets/intro.jpg',
|
||||
],
|
||||
]);
|
||||
|
||||
$response = $this->getJson("/api/display/module/{$version->id}/preview");
|
||||
|
||||
$response->assertSuccessful();
|
||||
$response->assertJsonPath('playlist.0.settings.logo_url', '/storage/display-media/logo.svg');
|
||||
$response->assertJsonPath('playlist.0.settings.brand_text', 'Musterstadt');
|
||||
$response->assertJsonPath('playlist.0.settings.footer_claim', 'Beratung im Schauraum');
|
||||
$response->assertJsonPath('playlist.0.settings.footer_url', 'cabinet-bielefeld.de');
|
||||
$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 () {
|
||||
$version = DisplayVersion::factory()->create([
|
||||
'type' => 'offers',
|
||||
'name' => 'Offers Module',
|
||||
]);
|
||||
$firstSlide = DisplayVersionItem::factory()->create([
|
||||
'display_version_id' => $version->id,
|
||||
'item_type' => 'slide',
|
||||
'content' => [
|
||||
'type' => 'product-hero',
|
||||
'title' => 'First Slide',
|
||||
'image_url' => '../assets/first.jpg',
|
||||
],
|
||||
'sort_order' => 0,
|
||||
]);
|
||||
$secondSlide = DisplayVersionItem::factory()->create([
|
||||
'display_version_id' => $version->id,
|
||||
'item_type' => 'slide',
|
||||
'content' => [
|
||||
'type' => 'product-details',
|
||||
'title' => 'Second Slide',
|
||||
'image_url' => '../assets/second.jpg',
|
||||
],
|
||||
'sort_order' => 1,
|
||||
]);
|
||||
|
||||
$response = $this->getJson("/api/display/module/{$version->id}/item/{$secondSlide->id}/preview");
|
||||
|
||||
$response->assertSuccessful();
|
||||
$response->assertJsonCount(1, 'playlist');
|
||||
$response->assertJsonCount(1, 'playlist.0.slides');
|
||||
$response->assertJsonPath('playlist.0.slides.0.title', 'Second Slide');
|
||||
$response->assertJsonMissingPath('playlist.0.slides.1');
|
||||
|
||||
expect($firstSlide->fresh()->content['title'])->toBe('First Slide');
|
||||
});
|
||||
|
||||
test('module item preview requires the item to belong to the module', function () {
|
||||
$version = DisplayVersion::factory()->create(['type' => 'offers']);
|
||||
$otherVersion = DisplayVersion::factory()->create(['type' => 'offers']);
|
||||
$item = DisplayVersionItem::factory()->create([
|
||||
'display_version_id' => $otherVersion->id,
|
||||
'item_type' => 'slide',
|
||||
]);
|
||||
|
||||
$this->getJson("/api/display/module/{$version->id}/item/{$item->id}/preview")
|
||||
->assertNotFound();
|
||||
});
|
||||
|
||||
test('display player keeps previews in a strict 9 by 16 viewport', function () {
|
||||
$player = file_get_contents(public_path('_cabinet/display/index.html'));
|
||||
|
||||
expect($player)
|
||||
->toContain('width: min(100vw, calc(100vh * 9 / 16));')
|
||||
->toContain('height: min(100vh, calc(100vw * 16 / 9));')
|
||||
->toContain('container-type: size;')
|
||||
->toContain('translate(${offsetX}px, ${offsetY}px) scale(${scale})')
|
||||
->toContain('this.settings.logo_url')
|
||||
->toContain('this.settings.footer_claim')
|
||||
->toContain('this.settings.footer_url')
|
||||
->toContain('this.settings.header_logo_url')
|
||||
->toContain('this.settings.qr_label');
|
||||
});
|
||||
|
||||
test('preview player pages are reachable for valid display and module previews', function () {
|
||||
$display = Display::factory()->create(['preview_token' => 'page-preview-token']);
|
||||
$version = DisplayVersion::factory()->create();
|
||||
$item = DisplayVersionItem::factory()->create(['display_version_id' => $version->id]);
|
||||
|
||||
$this->get('/preview/page-preview-token')
|
||||
->assertSuccessful();
|
||||
|
||||
$this->get("/preview/module/{$version->id}")
|
||||
->assertSuccessful();
|
||||
|
||||
$this->get("/preview/module/{$version->id}/item/{$item->id}")
|
||||
->assertSuccessful();
|
||||
});
|
||||
|
||||
test('existing display config api still works', function () {
|
||||
$response = $this->getJson('/api/display/config');
|
||||
|
||||
|
|
|
|||
|
|
@ -6,12 +6,13 @@ use App\Livewire\Admin\Cms\DisplayVersionList;
|
|||
use App\Models\DisplayVersion;
|
||||
use App\Models\DisplayVersionItem;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Livewire\Livewire;
|
||||
|
||||
beforeEach(function () {
|
||||
$portalDomain = config('domains.domain_portal');
|
||||
Livewire::withoutLazyLoading();
|
||||
url()->forceRootUrl('https://'.$portalDomain);
|
||||
URL::forceRootUrl('https://'.$portalDomain);
|
||||
});
|
||||
|
||||
// ========================================
|
||||
|
|
@ -19,7 +20,7 @@ beforeEach(function () {
|
|||
// ========================================
|
||||
|
||||
test('display version list requires authentication', function () {
|
||||
$response = $this->get(route('admin.cms.display-versions'));
|
||||
$response = $this->get(route('admin.cms.display-modules'));
|
||||
|
||||
$response->assertRedirect('/login');
|
||||
});
|
||||
|
|
@ -29,7 +30,7 @@ test('display version list renders for authenticated users', function () {
|
|||
|
||||
$this->actingAs($user);
|
||||
|
||||
$response = $this->get(route('admin.cms.display-versions'));
|
||||
$response = $this->get(route('admin.cms.display-modules'));
|
||||
|
||||
$response->assertSuccessful();
|
||||
$response->assertSeeLivewire(DisplayVersionList::class);
|
||||
|
|
@ -50,6 +51,8 @@ test('can create a display version', function () {
|
|||
expect($version->type)->toBe(DisplayVersionType::B2in);
|
||||
expect($version->settings)->toBeArray();
|
||||
expect($version->settings)->toHaveKey('theme');
|
||||
expect($version->settings)->toHaveKey('header_logo_url');
|
||||
expect($version->settings)->toHaveKey('footer_url');
|
||||
});
|
||||
|
||||
test('create version validates required fields', function () {
|
||||
|
|
@ -95,12 +98,59 @@ test('display version editor renders with correct version data', function () {
|
|||
|
||||
$this->actingAs($user);
|
||||
|
||||
$response = $this->get(route('admin.cms.display-version-edit', $version));
|
||||
$response = $this->get(route('admin.cms.display-module-edit', $version));
|
||||
|
||||
$response->assertSuccessful();
|
||||
$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 () {
|
||||
$user = User::factory()->create();
|
||||
$version = DisplayVersion::factory()->create(['name' => 'Preview Modul']);
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
$response = $this->get(route('admin.cms.display-module-edit', $version));
|
||||
|
||||
$response->assertSuccessful();
|
||||
$response->assertSee('Modul-Vorschau');
|
||||
$response->assertSee('/preview/module/'.$version->id, false);
|
||||
});
|
||||
|
||||
test('item edit modal renders module iframe preview', function () {
|
||||
$user = User::factory()->create();
|
||||
$version = DisplayVersion::factory()->create(['type' => 'offers']);
|
||||
$item = DisplayVersionItem::factory()->create([
|
||||
'display_version_id' => $version->id,
|
||||
'item_type' => 'slide',
|
||||
'content' => [
|
||||
'type' => 'product-hero',
|
||||
'title' => 'Preview Slide',
|
||||
'image_url' => '../assets/preview.jpg',
|
||||
],
|
||||
]);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(DisplayVersionEditor::class, ['displayVersion' => $version])
|
||||
->call('openItemModal', $item->id)
|
||||
->assertSee('Einzel-Vorschau im Bearbeiten-Dialog')
|
||||
->assertSee('/preview/module/'.$version->id.'/item/'.$item->id, false)
|
||||
->assertSee('Schließen');
|
||||
});
|
||||
|
||||
test('can add a video item to video-display version', function () {
|
||||
$user = User::factory()->create();
|
||||
$version = DisplayVersion::factory()->create(['type' => 'video-display']);
|
||||
|
|
@ -162,6 +212,36 @@ test('can edit an existing item', function () {
|
|||
expect($item->content['title'])->toBe('New Title');
|
||||
});
|
||||
|
||||
test('updating an item keeps modal open and refreshes iframe preview', function () {
|
||||
$user = User::factory()->create();
|
||||
$version = DisplayVersion::factory()->create(['type' => 'offers']);
|
||||
$item = DisplayVersionItem::factory()->create([
|
||||
'display_version_id' => $version->id,
|
||||
'item_type' => 'slide',
|
||||
'content' => [
|
||||
'type' => 'product-hero',
|
||||
'title' => 'Old Title',
|
||||
'image_url' => '../assets/old.jpg',
|
||||
],
|
||||
]);
|
||||
|
||||
$component = Livewire::actingAs($user)
|
||||
->test(DisplayVersionEditor::class, ['displayVersion' => $version])
|
||||
->call('openItemModal', $item->id);
|
||||
|
||||
$initialRefreshCounter = $component->get('previewFrameRefreshCounter');
|
||||
|
||||
$component
|
||||
->set('slideTitle', 'New Title')
|
||||
->call('saveItem')
|
||||
->assertSet('showItemModal', true)
|
||||
->assertSet('itemId', $item->id)
|
||||
->assertSee('Einzel-Vorschau im Bearbeiten-Dialog');
|
||||
|
||||
expect($item->fresh()->content['title'])->toBe('New Title');
|
||||
expect($component->get('previewFrameRefreshCounter'))->toBeGreaterThan($initialRefreshCounter);
|
||||
});
|
||||
|
||||
test('can delete an item', function () {
|
||||
$user = User::factory()->create();
|
||||
$version = DisplayVersion::factory()->create();
|
||||
|
|
@ -309,7 +389,31 @@ test('can save version settings', function () {
|
|||
->test(DisplayVersionEditor::class, ['displayVersion' => $version])
|
||||
->call('openSettingsModal')
|
||||
->set('settings.theme', 'light')
|
||||
->set('settings.header_claim', 'Custom Claim')
|
||||
->set('settings.footer_prefix', 'powered by')
|
||||
->call('saveSettings');
|
||||
|
||||
expect($version->fresh()->settings['theme'])->toBe('light');
|
||||
expect($version->fresh()->settings['header_claim'])->toBe('Custom Claim');
|
||||
expect($version->fresh()->settings['footer_prefix'])->toBe('powered by');
|
||||
});
|
||||
|
||||
test('module settings expose player chrome fields in the editor', function () {
|
||||
$user = User::factory()->create();
|
||||
$version = DisplayVersion::factory()->create(['type' => 'offers']);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(DisplayVersionEditor::class, ['displayVersion' => $version])
|
||||
->assertSee('Meta-Einstellungen für dieses Modul')
|
||||
->assertSee('Diese Werte gelten für die gesamte Media-Playlist bzw. alle Slides dieses Moduls.')
|
||||
->call('openSettingsModal')
|
||||
->assertSee('Branding')
|
||||
->assertSee('Logo URL')
|
||||
->assertDontSee('Logo Alt-Text')
|
||||
->assertSee('Brand-Text')
|
||||
->assertSee('Footer & QR für alle Slides')
|
||||
->assertSee('Footer-Claim')
|
||||
->assertSee('Web/QR-URL')
|
||||
->assertSee('Standard QR-Titel')
|
||||
->assertSee('QR-Unterzeile');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -54,6 +54,8 @@ it('immobilien show page loads for valid slug', function () {
|
|||
->assertSee('Creek Views 4')
|
||||
->assertSee('Al Jaddaf, Dubai')
|
||||
->assertSee('Investment-Einordnung')
|
||||
->assertSee('href="#projekt-anfrage"', false)
|
||||
->assertSee('id="projekt-anfrage"', false)
|
||||
->assertSee('Verfügbarkeit anfragen')
|
||||
->assertDontSee('Projektquelle');
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue