b2in/app/Livewire/Admin/Cms/DisplayVersionEditor.php
Kevin Adametz 3211569fee Store Display: Bilddauer mit Nachkommastelle (1,5 Sekunden)
Das Feld „Dauer (Sekunden)“ im b2in-Display nimmt Komma und Punkt an,
erlaubt 0,5 bis 120 Sekunden und speichert eine Zahl; der Player rechnet
unverändert mal 1000. Anzeige in der Liste mit Komma. Nachtrag 14.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-17 09:52:33 +00:00

788 lines
26 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace App\Livewire\Admin\Cms;
use App\Enums\DisplayVersionType;
use App\Models\DisplayMedia;
use App\Models\DisplayVersion;
use App\Models\DisplayVersionItem;
use App\Services\DisplayQrCodeService;
use App\Support\DisplayModuleSettings;
use Livewire\Attributes\On;
use Livewire\Component;
class DisplayVersionEditor extends Component
{
public DisplayVersion $version;
public string $versionName = '';
// Item Modal
public bool $showItemModal = false;
public ?int $itemId = null;
public string $itemType = '';
// Video-Display: Video fields
public string $videoFilename = '';
public string $videoTitle = '';
public int $videoPosition = 25;
public bool $videoIsActive = true;
// Video-Display: Footer fields
public string $footerHeadline = '';
public string $footerSubline = '';
public string $footerUrl = '';
public bool $footerIsActive = true;
// B2in: Media fields
public string $mediaType = 'image';
public string $mediaCategory = 'immobilien';
public string $mediaUrl = '';
public string $mediaHeadline = '';
public string $mediaSubline = '';
/** Anzeigedauer eines Bildes in Sekunden als Eingabetext, Komma oder Punkt erlaubt („1,5“). */
public string $mediaDuration = '10';
public bool $mediaIsActive = true;
// Offers: Slide fields (single dynamic detail layout)
public bool $slideShowLogo = true;
public string $slideLogoUrl = '';
public string $slideBrandText = '';
public int $slideDuration = 8000;
public string $slideImageUrl = '';
public string $slideBadge = '';
public bool $slideShowBadge = true;
public string $slideEyebrow = '';
public bool $slideShowEyebrow = true;
public string $slideTitle = '';
public string $slideSubline = '';
public bool $slideShowSubline = false;
public string $slidePrice = '';
public string $slideOriginalPrice = '';
public bool $slideStrikeOriginalPrice = false;
public string $slideTagText = '';
public bool $slideShowPrice = false;
/** @var array<string> */
public array $slideBullets = [];
public bool $slideShowBullets = true;
public string $slideDisclaimer = '';
public bool $slideShowDisclaimer = false;
public string $slideQrUrl = '';
public string $slideQrTitle = '';
public bool $slideShowQr = true;
public string $slideContact = '';
public bool $slideShowContact = true;
public string $slideBrandTagline = '';
public bool $slideIsActive = true;
// Settings Modal
public bool $showSettingsModal = false;
public array $settings = [];
public int $previewFrameRefreshCounter = 0;
/** @var array<string> */
public const BRAND_POSITIONS = ['top-left', 'top-right', 'bottom-left', 'bottom-right'];
public function mount(DisplayVersion $displayVersion): void
{
$this->version = $displayVersion;
$this->versionName = $displayVersion->name;
$this->settings = $this->settingsWithDefaults();
$this->normalizeBrandPositions();
}
public function updated(string $name): void
{
if (str_starts_with($name, 'settings.show_footer')
|| str_starts_with($name, 'settings.logo_position')
|| str_starts_with($name, 'settings.claim_position')) {
$this->normalizeBrandPositions();
}
}
public function toggleTheme(): void
{
$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
{
$this->validate([
'versionName' => 'required|string|max:255',
]);
$this->version->update(['name' => $this->versionName]);
$this->refreshModulePreview();
session()->flash('success', 'Name aktualisiert!');
}
// ========================================
// SETTINGS
// ========================================
public function openSettingsModal(): void
{
$this->settings = $this->settingsWithDefaults();
$this->showSettingsModal = true;
}
public function saveSettings(): void
{
$this->normalizeBrandPositions();
$this->settings = $this->settingsWithQrImage($this->settings);
$this->version->update(['settings' => $this->settings]);
$this->showSettingsModal = false;
$this->refreshModulePreview();
session()->flash('success', 'Einstellungen gespeichert!');
}
/**
* Keep the B2in logo/claim corners consistent:
* - bottom corners are only valid while the footer is hidden,
* - the claim can never sit in the same corner as the logo.
*/
private function normalizeBrandPositions(): void
{
if ($this->version->type !== DisplayVersionType::B2in) {
return;
}
$footerShown = ($this->settings['show_footer'] ?? true) !== false;
$allowed = $footerShown ? ['top-left', 'top-right'] : self::BRAND_POSITIONS;
$logo = $this->settings['logo_position'] ?? 'top-left';
$claim = $this->settings['claim_position'] ?? 'top-right';
$logo = $this->moveIntoAllowed($logo, $allowed);
$claim = $this->moveIntoAllowed($claim, $allowed);
if ($claim === $logo) {
$claim = collect($allowed)->first(fn (string $position) => $position !== $logo) ?? $claim;
}
$this->settings['logo_position'] = $logo;
$this->settings['claim_position'] = $claim;
}
/**
* @param array<string> $allowed
*/
private function moveIntoAllowed(string $position, array $allowed): string
{
if (in_array($position, $allowed, true)) {
return $position;
}
// Pull bottom corners up to the matching top corner when forbidden.
$fallback = str_replace('bottom-', 'top-', $position);
return in_array($fallback, $allowed, true) ? $fallback : ($allowed[0] ?? 'top-left');
}
// ========================================
// ITEM CRUD
// ========================================
public function openItemModal(?int $id = null, string $type = ''): void
{
$this->resetItemForm();
if ($id) {
$item = DisplayVersionItem::findOrFail($id);
$this->itemId = $item->id;
$this->itemType = $item->item_type;
$this->loadItemContent($item);
} else {
$this->itemType = $type ?: $this->defaultItemType();
}
$this->showItemModal = true;
}
public function saveItem(): void
{
if ($this->itemType === 'media' && $this->mediaType === 'image' && self::parseDuration($this->mediaDuration) === null) {
$this->addError('mediaDuration', 'Bitte eine Dauer zwischen 0,5 und 120 Sekunden angeben, z. B. 1,5.');
return;
}
$content = $this->buildItemContent();
$isActive = $this->getActiveFlag();
if ($this->itemId) {
$item = DisplayVersionItem::findOrFail($this->itemId);
$item->update([
'content' => $content,
'is_active' => $isActive,
]);
session()->flash('success', 'Inhalt aktualisiert!');
} else {
$maxSort = DisplayVersionItem::where('display_version_id', $this->version->id)
->where('item_type', $this->itemType)
->max('sort_order') ?? -1;
$item = DisplayVersionItem::create([
'display_version_id' => $this->version->id,
'item_type' => $this->itemType,
'content' => $content,
'sort_order' => $maxSort + 1,
'is_active' => $isActive,
]);
session()->flash('success', 'Inhalt hinzugefügt!');
}
$this->itemId = $item->id;
$this->itemType = $item->item_type;
$this->loadItemContent($item->fresh());
$this->showItemModal = true;
$this->refreshModulePreview();
}
/**
* Sekunden aus der Eingabe lesen: „1,5“, „1.5“ und „10“ sind gültig, gerundet auf Zehntel.
* Erlaubt sind 0,5 bis 120 Sekunden; alles andere ergibt null.
*/
public static function parseDuration(string|int|float $input): ?float
{
$normalized = str_replace(',', '.', trim((string) $input));
if ($normalized === '' || ! is_numeric($normalized)) {
return null;
}
$seconds = round((float) $normalized, 1);
if ($seconds < 0.5 || $seconds > 120) {
return null;
}
return $seconds;
}
/**
* Sekunden für die Anzeige: ganze Zahlen ohne Nachkommastelle, sonst mit Komma („1,5“).
*/
public static function formatDuration(int|float|string|null $seconds): string
{
$seconds = (float) str_replace(',', '.', (string) ($seconds ?? 10));
if (floor($seconds) === $seconds) {
return (string) (int) $seconds;
}
return str_replace('.', ',', (string) round($seconds, 1));
}
public function deleteItem(int $id): void
{
DisplayVersionItem::findOrFail($id)->delete();
$this->refreshModulePreview();
session()->flash('success', 'Inhalt gelöscht!');
}
public function toggleItemStatus(int $id): void
{
$item = DisplayVersionItem::findOrFail($id);
$item->update(['is_active' => ! $item->is_active]);
$this->refreshModulePreview();
}
public function moveItem(int $id, string $direction): void
{
$item = DisplayVersionItem::findOrFail($id);
$currentOrder = $item->sort_order;
$swapItem = DisplayVersionItem::where('display_version_id', $this->version->id)
->where('item_type', $item->item_type)
->where('sort_order', $direction === 'up' ? $currentOrder - 1 : $currentOrder + 1)
->first();
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;
}
/**
* @return array{status: 'match'|'mismatch'|'unknown', label: string, color: string, description: string}|null
*/
public function displayMediaFormatStatus(?string $url): ?array
{
$url = trim((string) $url);
if ($url === '') {
return null;
}
return $this->displayMediaForUrl($url)?->getDisplayFormatStatus() ?? [
'status' => 'unknown',
'label' => 'Format unbekannt',
'color' => 'zinc',
'description' => 'Dieses Medium wurde nicht in der Mediathek gefunden. Ziel ist 9:16 im Hochformat.',
];
}
#[On('display-media-selected')]
public function onDisplayMediaSelected(string $field, ?int $mediaId, ?string $url): void
{
if (! $url) {
return;
}
match ($field) {
'videoFilename' => $this->videoFilename = $url,
'mediaUrl' => $this->mediaUrl = $url,
'slideImageUrl' => $this->slideImageUrl = $url,
'slideLogoUrl' => $this->slideLogoUrl = $url,
'settings.header_logo_url' => $this->settings['header_logo_url'] = $url,
default => null,
};
// The media type for a B2in playlist item is derived from the chosen
// media – the type selector in the form is only an informational hint.
if ($field === 'mediaUrl' && $mediaId) {
$media = DisplayMedia::find($mediaId);
if ($media) {
$this->mediaType = $media->isVideo() ? 'video' : 'image';
}
}
}
public function addBullet(): void
{
$this->slideBullets[] = '';
}
public function removeBullet(int $index): void
{
unset($this->slideBullets[$index]);
$this->slideBullets = array_values($this->slideBullets);
}
public function closeItemModal(): void
{
$this->showItemModal = false;
$this->resetItemForm();
}
// ========================================
// HELPERS
// ========================================
private function loadItemContent(DisplayVersionItem $item): void
{
$content = $item->content;
$isActive = (bool) $item->is_active;
match ($item->item_type) {
'video' => $this->loadVideoContent($content, $isActive),
'footer' => $this->loadFooterContent($content, $isActive),
'media' => $this->loadMediaContent($content, $isActive),
'slide' => $this->loadSlideContent($content, $isActive),
default => null,
};
}
/**
* @param array<string, mixed> $content
*/
private function loadVideoContent(array $content, bool $isActive): void
{
$this->videoFilename = $content['filename'] ?? '';
$this->videoTitle = $content['title'] ?? '';
$this->videoPosition = $content['position'] ?? 25;
$this->videoIsActive = $isActive;
}
/**
* @param array<string, mixed> $content
*/
private function loadFooterContent(array $content, bool $isActive): void
{
$this->footerHeadline = $content['headline'] ?? '';
$this->footerSubline = $content['subline'] ?? '';
$this->footerUrl = $content['url'] ?? '';
$this->footerIsActive = $isActive;
}
/**
* @param array<string, mixed> $content
*/
private function loadMediaContent(array $content, bool $isActive): void
{
$this->mediaType = $content['media_type'] ?? 'image';
$this->mediaCategory = $content['category'] ?? 'immobilien';
$this->mediaUrl = $content['media_url'] ?? '';
$this->mediaHeadline = $content['headline'] ?? '';
$this->mediaSubline = $content['subline'] ?? '';
$this->mediaDuration = self::formatDuration($content['duration_seconds'] ?? 10);
$this->mediaIsActive = $isActive;
}
/**
* @param array<string, mixed> $content
*/
private function loadSlideContent(array $content, bool $isActive): void
{
$this->slideShowLogo = $content['show_logo'] ?? true;
$this->slideLogoUrl = $content['logo_url'] ?? '';
$this->slideBrandText = $content['brand_text'] ?? '';
$this->slideDuration = $content['duration'] ?? 8000;
$this->slideImageUrl = $content['image_url'] ?? '';
$this->slideBadge = $content['badge_text'] ?? '';
$this->slideEyebrow = $content['eyebrow'] ?? '';
$this->slideTitle = $content['title'] ?? '';
$this->slideSubline = $content['subline'] ?? '';
$this->slidePrice = $content['price'] ?? '';
$this->slideOriginalPrice = $content['original_price'] ?? '';
$this->slideStrikeOriginalPrice = $content['strike_original_price'] ?? false;
$this->slideTagText = $content['tag_text'] ?? '';
$this->slideBullets = $content['bullets'] ?? [];
$this->slideDisclaimer = $content['disclaimer'] ?? '';
$this->slideQrUrl = $content['qr_url'] ?? '';
$this->slideQrTitle = $content['qr_title'] ?? '';
$this->slideContact = $content['contact'] ?? '';
$this->slideBrandTagline = $content['brand_tagline'] ?? '';
$this->slideIsActive = $isActive;
// Show flags fall back to "is there content?" so slides created before
// the dynamic detail layout keep rendering exactly as they did.
$this->slideShowBadge = $content['show_badge'] ?? ($content['badge_text'] ?? '') !== '';
$this->slideShowEyebrow = $content['show_eyebrow'] ?? ($content['eyebrow'] ?? '') !== '';
$this->slideShowSubline = $content['show_subline'] ?? ($content['subline'] ?? '') !== '';
$this->slideShowBullets = $content['show_bullets'] ?? ! empty($content['bullets']);
$this->slideShowPrice = $content['show_price'] ?? ($content['price'] ?? '') !== '';
$this->slideShowDisclaimer = $content['show_disclaimer'] ?? ($content['disclaimer'] ?? '') !== '';
$this->slideShowQr = $content['show_qr'] ?? ($content['qr_url'] ?? '') !== '';
$this->slideShowContact = $content['show_contact'] ?? ($content['contact'] ?? '') !== '';
}
/**
* @return array<string, mixed>
*/
private function buildItemContent(): array
{
$content = match ($this->itemType) {
'video' => [
'filename' => $this->videoFilename,
'title' => $this->videoTitle,
'position' => $this->videoPosition,
],
'footer' => [
'headline' => $this->footerHeadline,
'subline' => $this->footerSubline,
'url' => $this->footerUrl ?: null,
],
'media' => [
'media_type' => $this->mediaType,
'category' => $this->mediaCategory,
'media_url' => $this->mediaUrl,
'headline' => $this->mediaHeadline,
'subline' => $this->mediaSubline,
'duration_seconds' => self::parseDuration($this->mediaDuration) ?? 10,
],
'slide' => [
'type' => 'detail',
'show_logo' => $this->slideShowLogo,
'logo_url' => $this->slideLogoUrl,
'brand_text' => $this->slideBrandText,
'duration' => $this->slideDuration,
'image_url' => $this->slideImageUrl,
'badge_text' => $this->slideBadge,
'show_badge' => $this->slideShowBadge,
'eyebrow' => $this->slideEyebrow,
'show_eyebrow' => $this->slideShowEyebrow,
'title' => $this->slideTitle,
'subline' => $this->slideSubline,
'show_subline' => $this->slideShowSubline,
'price' => $this->slidePrice,
'original_price' => $this->slideOriginalPrice,
'strike_original_price' => $this->slideStrikeOriginalPrice,
'tag_text' => $this->slideTagText,
'show_price' => $this->slideShowPrice,
'bullets' => array_values(array_filter($this->slideBullets, fn (string $bullet) => trim($bullet) !== '')),
'show_bullets' => $this->slideShowBullets,
'disclaimer' => $this->slideDisclaimer,
'show_disclaimer' => $this->slideShowDisclaimer,
'qr_url' => $this->slideQrUrl,
'qr_title' => $this->slideQrTitle,
'show_qr' => $this->slideShowQr,
'contact' => $this->slideContact,
'show_contact' => $this->slideShowContact,
'brand_tagline' => $this->slideBrandTagline,
],
default => [],
};
return $this->contentWithQrImage($content);
}
/**
* @param array<string, mixed> $content
* @return array<string, mixed>
*/
private function contentWithQrImage(array $content): array
{
$qrUrl = match ($this->itemType) {
'footer' => $content['url'] ?? null,
'slide' => $content['qr_url'] ?? null,
default => null,
};
$qrCodeService = app(DisplayQrCodeService::class);
$qrCode = $qrCodeService->generateForUrl(is_string($qrUrl) ? $qrUrl : null);
if ($qrCode) {
$content['qr_image_path'] = $qrCode['path'];
$content['qr_image_url'] = $qrCode['url'];
} elseif ($this->itemId && is_string($qrUrl) && $qrUrl !== '') {
$existingContent = DisplayVersionItem::find($this->itemId)?->content ?? [];
$existingQrUrl = match ($this->itemType) {
'footer' => $existingContent['url'] ?? null,
'slide' => $existingContent['qr_url'] ?? null,
default => null,
};
$preservedExistingQrImage = false;
if (is_string($existingQrUrl)
&& $qrCodeService->normalizeUrl($existingQrUrl) === $qrCodeService->normalizeUrl($qrUrl)
&& ! empty($existingContent['qr_image_path'])
&& ! empty($existingContent['qr_image_url'])) {
$content['qr_image_path'] = $existingContent['qr_image_path'];
$content['qr_image_url'] = $existingContent['qr_image_url'];
$preservedExistingQrImage = true;
}
if (! $preservedExistingQrImage) {
unset($content['qr_image_path'], $content['qr_image_url']);
}
} else {
unset($content['qr_image_path'], $content['qr_image_url']);
}
return $content;
}
/**
* @param array<string, mixed> $settings
* @return array<string, mixed>
*/
private function settingsWithQrImage(array $settings): array
{
if ($this->version->type !== DisplayVersionType::B2in) {
return $settings;
}
$qrUrl = $settings['qr_url'] ?? $settings['footer_url'] ?? null;
$qrCodeService = app(DisplayQrCodeService::class);
$qrCode = $qrCodeService->generateForUrl(is_string($qrUrl) ? $qrUrl : null);
if ($qrCode) {
$settings['qr_image_path'] = $qrCode['path'];
$settings['qr_image_url'] = $qrCode['url'];
} elseif (is_string($qrUrl) && $qrUrl !== '') {
$existingSettings = $this->settingsWithDefaults();
$existingQrUrl = $existingSettings['qr_url'] ?? $existingSettings['footer_url'] ?? null;
$preservedExistingQrImage = false;
if (is_string($existingQrUrl)
&& $qrCodeService->normalizeUrl($existingQrUrl) === $qrCodeService->normalizeUrl($qrUrl)
&& ! empty($existingSettings['qr_image_path'])
&& ! empty($existingSettings['qr_image_url'])) {
$settings['qr_image_path'] = $existingSettings['qr_image_path'];
$settings['qr_image_url'] = $existingSettings['qr_image_url'];
$preservedExistingQrImage = true;
}
if (! $preservedExistingQrImage) {
unset($settings['qr_image_path'], $settings['qr_image_url']);
}
} else {
unset($settings['qr_image_path'], $settings['qr_image_url']);
}
return $settings;
}
private function getActiveFlag(): bool
{
return match ($this->itemType) {
'video' => $this->videoIsActive,
'footer' => $this->footerIsActive,
'media' => $this->mediaIsActive,
'slide' => $this->slideIsActive,
default => true,
};
}
private function defaultItemType(): string
{
return match ($this->version->type) {
DisplayVersionType::VideoDisplay => 'video',
DisplayVersionType::B2in => 'media',
DisplayVersionType::Offers => 'slide',
};
}
private function resetItemForm(): void
{
$this->itemId = null;
$this->itemType = '';
$this->videoFilename = '';
$this->videoTitle = '';
$this->videoPosition = 25;
$this->videoIsActive = true;
$this->footerHeadline = '';
$this->footerSubline = '';
$this->footerUrl = '';
$this->footerIsActive = true;
$this->mediaType = 'image';
$this->mediaCategory = 'immobilien';
$this->mediaUrl = '';
$this->mediaHeadline = '';
$this->mediaSubline = '';
$this->mediaDuration = '10';
$this->mediaIsActive = true;
$this->slideShowLogo = true;
$this->slideLogoUrl = '';
$this->slideBrandText = '';
$this->slideDuration = 8000;
$this->slideImageUrl = '';
$this->slideBadge = '';
$this->slideShowBadge = true;
$this->slideEyebrow = '';
$this->slideShowEyebrow = true;
$this->slideTitle = '';
$this->slideSubline = '';
$this->slideShowSubline = false;
$this->slidePrice = '';
$this->slideOriginalPrice = '';
$this->slideStrikeOriginalPrice = false;
$this->slideTagText = '';
$this->slideShowPrice = false;
$this->slideBullets = [];
$this->slideShowBullets = true;
$this->slideDisclaimer = '';
$this->slideShowDisclaimer = false;
$this->slideQrUrl = '';
$this->slideQrTitle = '';
$this->slideShowQr = true;
$this->slideContact = '';
$this->slideShowContact = true;
$this->slideBrandTagline = '';
$this->slideIsActive = true;
}
private function refreshModulePreview(): void
{
$this->previewFrameRefreshCounter++;
}
private function displayMediaForUrl(string $url): ?DisplayMedia
{
$mediaPath = $this->mediaPathFromUrl($url);
return DisplayMedia::query()
->where('external_url', $url)
->when($mediaPath !== null, fn ($query) => $query->orWhere('path', $mediaPath))
->first();
}
private function mediaPathFromUrl(string $url): ?string
{
$path = parse_url($url, PHP_URL_PATH);
$path = is_string($path) ? $path : $url;
$path = ltrim($path, '/');
if (str_starts_with($path, 'storage/')) {
return substr($path, strlen('storage/'));
}
if (str_starts_with($path, 'display-media/')) {
return $path;
}
return null;
}
/**
* @return array<string, mixed>
*/
private function settingsWithDefaults(): array
{
return DisplayModuleSettings::merge($this->version->type, $this->version->settings);
}
public function render()
{
$items = $this->version->items()->get()->groupBy('item_type');
return view('livewire.admin.cms.display-version-editor', [
'items' => $items,
]);
}
}