Compare commits
5 commits
6c6d683b9a
...
193d37b1ce
| Author | SHA1 | Date | |
|---|---|---|---|
| 193d37b1ce | |||
| 2a34adc515 | |||
| 7491d20d39 | |||
| 6c2da9bdac | |||
| bebfa63e87 |
29 changed files with 1633 additions and 210 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -5,6 +5,7 @@
|
|||
/public/hot
|
||||
/public/storage
|
||||
/public/vendor
|
||||
/public/flux
|
||||
/storage/*.key
|
||||
/storage/app
|
||||
/storage/framework
|
||||
|
|
|
|||
84
app/Console/Commands/PublishFluxAssets.php
Normal file
84
app/Console/Commands/PublishFluxAssets.php
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use RuntimeException;
|
||||
|
||||
class PublishFluxAssets extends Command
|
||||
{
|
||||
protected $signature = 'flux:publish-assets {--force : Overwrite existing published Flux assets}';
|
||||
|
||||
protected $description = 'Publish Flux JavaScript assets to public/flux for static delivery (like livewire:publish --assets)';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$sourceDir = $this->resolveSourceDirectory();
|
||||
$targetDir = public_path('flux');
|
||||
|
||||
if (! File::isDirectory($targetDir)) {
|
||||
File::makeDirectory($targetDir, 0755, true);
|
||||
}
|
||||
|
||||
$files = [
|
||||
'flux.js',
|
||||
'flux.min.js',
|
||||
'editor.js',
|
||||
'editor.min.js',
|
||||
'editor.css',
|
||||
];
|
||||
|
||||
$published = 0;
|
||||
|
||||
foreach ($files as $file) {
|
||||
$source = $sourceDir.'/'.$file;
|
||||
|
||||
if (! is_file($source)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$target = $targetDir.'/'.$file;
|
||||
|
||||
if (is_file($target) && ! $this->option('force') && hash_file('sha256', $source) === hash_file('sha256', $target)) {
|
||||
$this->line(" <comment>•</comment> {$file} (unverändert)");
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
File::copy($source, $target);
|
||||
$this->line(" <info>✓</info> {$file}");
|
||||
$published++;
|
||||
}
|
||||
|
||||
if ($published === 0) {
|
||||
$this->info('Flux-Assets sind bereits aktuell.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->info("{$published} Flux-Asset(s) nach public/flux/ veröffentlicht.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function resolveSourceDirectory(): string
|
||||
{
|
||||
$proDist = base_path('vendor/livewire/flux-pro/dist');
|
||||
|
||||
if (is_dir($proDist)) {
|
||||
return $proDist;
|
||||
}
|
||||
|
||||
$freeDist = base_path('vendor/livewire/flux/dist');
|
||||
|
||||
if (is_dir($freeDist)) {
|
||||
return $freeDist;
|
||||
}
|
||||
|
||||
throw new RuntimeException('Flux vendor assets not found. Run composer install first.');
|
||||
}
|
||||
}
|
||||
|
|
@ -69,9 +69,16 @@ class DisplayMediaPicker extends Component
|
|||
$service = app(DisplayMediaService::class);
|
||||
$lastMedia = null;
|
||||
|
||||
try {
|
||||
foreach ($this->quickUploads as $file) {
|
||||
$lastMedia = $service->storeUpload($file);
|
||||
}
|
||||
} catch (\RuntimeException $exception) {
|
||||
$this->quickUploads = [];
|
||||
$this->addError('quickUploads', $exception->getMessage());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->quickUploads = [];
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ 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;
|
||||
|
|
@ -174,6 +175,7 @@ class DisplayVersionEditor extends Component
|
|||
public function saveSettings(): void
|
||||
{
|
||||
$this->normalizeBrandPositions();
|
||||
$this->settings = $this->settingsWithQrImage($this->settings);
|
||||
$this->version->update(['settings' => $this->settings]);
|
||||
$this->showSettingsModal = false;
|
||||
$this->refreshModulePreview();
|
||||
|
|
@ -322,6 +324,25 @@ class DisplayVersionEditor extends Component
|
|||
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
|
||||
{
|
||||
|
|
@ -463,7 +484,7 @@ class DisplayVersionEditor extends Component
|
|||
*/
|
||||
private function buildItemContent(): array
|
||||
{
|
||||
return match ($this->itemType) {
|
||||
$content = match ($this->itemType) {
|
||||
'video' => [
|
||||
'filename' => $this->videoFilename,
|
||||
'title' => $this->videoTitle,
|
||||
|
|
@ -514,6 +535,95 @@ class DisplayVersionEditor extends Component
|
|||
],
|
||||
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
|
||||
|
|
@ -590,6 +700,33 @@ class DisplayVersionEditor extends Component
|
|||
$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>
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -80,6 +80,55 @@ class DisplayVersionList extends Component
|
|||
$version->update(['is_active' => ! $version->is_active]);
|
||||
}
|
||||
|
||||
public function copyVersion(int $id): void
|
||||
{
|
||||
$source = DisplayVersion::query()->with('items')->findOrFail($id);
|
||||
|
||||
$copy = DB::transaction(function () use ($source) {
|
||||
$copy = DisplayVersion::create([
|
||||
'name' => $this->copyNameFor($source->name),
|
||||
'type' => $source->type,
|
||||
'settings' => $source->settings ?? [],
|
||||
'is_active' => $source->is_active,
|
||||
]);
|
||||
|
||||
foreach ($source->items as $item) {
|
||||
$copy->items()->create([
|
||||
'item_type' => $item->item_type,
|
||||
'content' => $item->content,
|
||||
'sort_order' => $item->sort_order,
|
||||
'is_active' => $item->is_active,
|
||||
]);
|
||||
}
|
||||
|
||||
return $copy;
|
||||
});
|
||||
|
||||
session()->flash('success', 'Modul "'.$source->name.'" wurde als "'.$copy->name.'" kopiert!');
|
||||
}
|
||||
|
||||
private function copyNameFor(string $sourceName): string
|
||||
{
|
||||
$baseName = preg_match('/^Kopie \d+ - (.+)$/', $sourceName, $matches) === 1
|
||||
? $matches[1]
|
||||
: $sourceName;
|
||||
|
||||
$maxCopyNumber = 0;
|
||||
|
||||
DisplayVersion::query()
|
||||
->where('name', 'like', 'Kopie % - '.$baseName)
|
||||
->pluck('name')
|
||||
->each(function (string $name) use ($baseName, &$maxCopyNumber): void {
|
||||
if (preg_match('/^Kopie (\d+) - '.preg_quote($baseName, '/').'$/', $name, $matches) !== 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
$maxCopyNumber = max($maxCopyNumber, (int) $matches[1]);
|
||||
});
|
||||
|
||||
return 'Kopie '.($maxCopyNumber + 1).' - '.$baseName;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ class QuickStatus extends Component
|
|||
public function mount(?string $k = null): void
|
||||
{
|
||||
$validKey = config('domains.cabinet_status_key');
|
||||
$key = $k ?? request()->query('k');
|
||||
$key = $k ?? request()->query('k') ?? request()->query('key');
|
||||
if (! $validKey || $key !== $validKey) {
|
||||
$this->authorized = false;
|
||||
|
||||
|
|
@ -92,13 +92,6 @@ class QuickStatus extends Component
|
|||
'noticeSubtext.max' => 'Subtext max. 80 Zeichen.',
|
||||
]);
|
||||
|
||||
$showsNotice = in_array($this->storeStatus, ['notice', 'warning'], true);
|
||||
|
||||
if (! $showsNotice) {
|
||||
$this->noticeHeadline = '';
|
||||
$this->noticeSubtext = '';
|
||||
}
|
||||
|
||||
CabinetTabletSetting::current()->update([
|
||||
'store_status' => $this->storeStatus,
|
||||
'notice_headline' => $this->noticeHeadline ?: null,
|
||||
|
|
|
|||
|
|
@ -12,6 +12,10 @@ class DisplayMedia extends Model
|
|||
/** @use HasFactory<\Database\Factories\DisplayMediaFactory> */
|
||||
use HasFactory;
|
||||
|
||||
private const DISPLAY_TARGET_RATIO = 9 / 16;
|
||||
|
||||
private const DISPLAY_RATIO_TOLERANCE = 0.03;
|
||||
|
||||
protected $table = 'display_media';
|
||||
|
||||
protected $fillable = [
|
||||
|
|
@ -50,13 +54,13 @@ class DisplayMedia extends Model
|
|||
return $this->external_url;
|
||||
}
|
||||
|
||||
return Storage::disk($this->disk)->url($this->path);
|
||||
return Storage::url($this->path);
|
||||
}
|
||||
|
||||
public function getThumbnailUrl(): ?string
|
||||
{
|
||||
if ($this->thumbnail_path) {
|
||||
return Storage::disk($this->disk)->url($this->thumbnail_path);
|
||||
return Storage::url($this->thumbnail_path);
|
||||
}
|
||||
|
||||
if ($this->isUpload() && $this->isImage()) {
|
||||
|
|
@ -66,6 +70,57 @@ class DisplayMedia extends Model
|
|||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{status: 'match'|'mismatch'|'unknown', label: string, color: string, description: string}
|
||||
*/
|
||||
public function getDisplayFormatStatus(): array
|
||||
{
|
||||
$dimensions = $this->getDimensions();
|
||||
|
||||
if ($dimensions === null) {
|
||||
return [
|
||||
'status' => 'unknown',
|
||||
'label' => 'Format unbekannt',
|
||||
'color' => 'zinc',
|
||||
'description' => 'Keine Abmessungen gespeichert. Ziel ist 9:16 im Hochformat.',
|
||||
];
|
||||
}
|
||||
|
||||
$ratio = $dimensions['width'] / $dimensions['height'];
|
||||
$matchesTarget = abs($ratio - self::DISPLAY_TARGET_RATIO) <= self::DISPLAY_TARGET_RATIO * self::DISPLAY_RATIO_TOLERANCE;
|
||||
$description = sprintf(
|
||||
'%dx%d px, erkanntes Verhältnis %s. Ziel ist 9:16 im Hochformat.',
|
||||
$dimensions['width'],
|
||||
$dimensions['height'],
|
||||
$this->formatRatio($dimensions['width'], $dimensions['height']),
|
||||
);
|
||||
|
||||
return [
|
||||
'status' => $matchesTarget ? 'match' : 'mismatch',
|
||||
'label' => $matchesTarget ? 'Format passt' : 'Format passt nicht',
|
||||
'color' => $matchesTarget ? 'green' : 'amber',
|
||||
'description' => $description,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{width: int, height: int}|null
|
||||
*/
|
||||
public function getDimensions(): ?array
|
||||
{
|
||||
$width = $this->metadata['width'] ?? null;
|
||||
$height = $this->metadata['height'] ?? null;
|
||||
|
||||
if (! is_numeric($width) || ! is_numeric($height) || (int) $width <= 0 || (int) $height <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'width' => (int) $width,
|
||||
'height' => (int) $height,
|
||||
];
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// TYPE CHECKS
|
||||
// ========================================
|
||||
|
|
@ -117,6 +172,24 @@ class DisplayMedia extends Model
|
|||
return $this->title ?: $this->filename;
|
||||
}
|
||||
|
||||
private function formatRatio(int $width, int $height): string
|
||||
{
|
||||
$divisor = $this->greatestCommonDivisor($width, $height);
|
||||
|
||||
return ($width / $divisor).':'.($height / $divisor);
|
||||
}
|
||||
|
||||
private function greatestCommonDivisor(int $first, int $second): int
|
||||
{
|
||||
while ($second !== 0) {
|
||||
$remainder = $first % $second;
|
||||
$first = $second;
|
||||
$second = $remainder;
|
||||
}
|
||||
|
||||
return max($first, 1);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// SCOPES
|
||||
// ========================================
|
||||
|
|
|
|||
|
|
@ -7,12 +7,15 @@ use Illuminate\Http\UploadedFile;
|
|||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use RuntimeException;
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
class DisplayMediaService
|
||||
{
|
||||
/**
|
||||
* Store an uploaded file and create a DisplayMedia record.
|
||||
*
|
||||
* @throws RuntimeException When the file cannot be written to storage.
|
||||
*/
|
||||
public function storeUpload(UploadedFile $file, ?string $collection = null): DisplayMedia
|
||||
{
|
||||
|
|
@ -22,7 +25,11 @@ class DisplayMediaService
|
|||
$datePath = now()->format('Y/m');
|
||||
$relativePath = "display-media/{$datePath}/{$storageName}";
|
||||
|
||||
Storage::disk('public')->putFileAs("display-media/{$datePath}", $file, $storageName);
|
||||
$storedPath = Storage::disk('public')->putFileAs("display-media/{$datePath}", $file, $storageName);
|
||||
|
||||
if ($storedPath === false || ! Storage::disk('public')->exists($relativePath)) {
|
||||
throw new RuntimeException("Die Datei \"{$filename}\" konnte nicht im Storage gespeichert werden. Bitte Schreibrechte und Speicherplatz prüfen.");
|
||||
}
|
||||
|
||||
$type = in_array($extension, ['mp4', 'webm', 'mov']) ? 'video' : 'image';
|
||||
|
||||
|
|
@ -33,6 +40,13 @@ class DisplayMediaService
|
|||
$metadata['width'] = $dimensions[0];
|
||||
$metadata['height'] = $dimensions[1];
|
||||
}
|
||||
} else {
|
||||
$dimensions = $this->readVideoDimensions($file->getRealPath());
|
||||
|
||||
if ($dimensions !== null) {
|
||||
$metadata['width'] = $dimensions['width'];
|
||||
$metadata['height'] = $dimensions['height'];
|
||||
}
|
||||
}
|
||||
|
||||
$media = DisplayMedia::create([
|
||||
|
|
@ -122,6 +136,43 @@ class DisplayMediaService
|
|||
return $process->isSuccessful() && is_file($thumbnailPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{width: int, height: int}|null
|
||||
*/
|
||||
private function readVideoDimensions(string $videoPath): ?array
|
||||
{
|
||||
$process = new Process([
|
||||
'ffprobe',
|
||||
'-v', 'error',
|
||||
'-select_streams', 'v:0',
|
||||
'-show_entries', 'stream=width,height',
|
||||
'-of', 'csv=s=x:p=0',
|
||||
$videoPath,
|
||||
]);
|
||||
$process->setTimeout(30);
|
||||
|
||||
try {
|
||||
$process->run();
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! $process->isSuccessful()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$dimensions = trim($process->getOutput());
|
||||
|
||||
if (! preg_match('/^(?<width>\d+)x(?<height>\d+)$/', $dimensions, $matches)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'width' => (int) $matches['width'],
|
||||
'height' => (int) $matches['height'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a DisplayMedia record from an external URL.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -90,6 +90,10 @@ class DisplayPlaylistConfigBuilder
|
|||
$data['url'] = $item->content['url'];
|
||||
}
|
||||
|
||||
if (! empty($item->content['qr_image_url'])) {
|
||||
$data['qr_image_url'] = $item->content['qr_image_url'];
|
||||
}
|
||||
|
||||
return $data;
|
||||
});
|
||||
|
||||
|
|
@ -170,6 +174,7 @@ class DisplayPlaylistConfigBuilder
|
|||
'disclaimer' => $content['disclaimer'] ?? '',
|
||||
'show_disclaimer' => $content['show_disclaimer'] ?? ($content['disclaimer'] ?? '') !== '',
|
||||
'qr_url' => $content['qr_url'] ?? '',
|
||||
'qr_image_url' => $content['qr_image_url'] ?? '',
|
||||
'qr_title' => $content['qr_title'] ?? '',
|
||||
'show_qr' => $content['show_qr'] ?? ($content['qr_url'] ?? '') !== '',
|
||||
'contact' => $content['contact'] ?? '',
|
||||
|
|
|
|||
71
app/Services/DisplayQrCodeService.php
Normal file
71
app/Services/DisplayQrCodeService.php
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class DisplayQrCodeService
|
||||
{
|
||||
/**
|
||||
* @return array{path: string, url: string}|null
|
||||
*/
|
||||
public function generateForUrl(?string $url): ?array
|
||||
{
|
||||
$normalizedUrl = $this->normalizeUrl($url);
|
||||
|
||||
if ($normalizedUrl === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$path = 'display-qr-codes/'.sha1($normalizedUrl).'.png';
|
||||
$disk = Storage::disk('public');
|
||||
|
||||
if (! $disk->exists($path)) {
|
||||
try {
|
||||
$response = Http::timeout(10)
|
||||
->retry(2, 300)
|
||||
->get($this->remoteQrUrl($normalizedUrl));
|
||||
|
||||
if (! $response->successful() || $response->body() === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$disk->put($path, $response->body());
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'path' => $path,
|
||||
'url' => asset('storage/'.$path),
|
||||
];
|
||||
}
|
||||
|
||||
public function normalizeUrl(?string $url): string
|
||||
{
|
||||
$url = trim((string) $url);
|
||||
|
||||
if ($url === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (str_starts_with($url, 'http://') || str_starts_with($url, 'https://')) {
|
||||
return $url;
|
||||
}
|
||||
|
||||
return 'https://'.$url;
|
||||
}
|
||||
|
||||
private function remoteQrUrl(string $url): string
|
||||
{
|
||||
return 'https://api.qrserver.com/v1/create-qr-code/?'.http_build_query([
|
||||
'size' => '300x300',
|
||||
'margin' => '8',
|
||||
'color' => '000000',
|
||||
'bgcolor' => 'ffffff',
|
||||
'data' => $url,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -6,6 +6,14 @@ use App\Enums\DisplayVersionType;
|
|||
|
||||
class DisplayModuleSettings
|
||||
{
|
||||
/** @var list<string> */
|
||||
private const B2IN_BOOLEAN_KEYS = [
|
||||
'show_logo',
|
||||
'show_claim',
|
||||
'show_footer',
|
||||
'display_active',
|
||||
];
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
|
|
@ -19,7 +27,7 @@ class DisplayModuleSettings
|
|||
],
|
||||
DisplayVersionType::B2in->value => [
|
||||
'theme' => 'dark',
|
||||
'header_logo_url' => '../assets/b2in-logo-positive.svg',
|
||||
'header_logo_url' => '/_cabinet/assets/b2in-logo-positive.svg',
|
||||
'header_claim' => 'Connecting Design & Property',
|
||||
'logo_position' => 'top-left',
|
||||
'claim_position' => 'top-right',
|
||||
|
|
@ -55,6 +63,44 @@ class DisplayModuleSettings
|
|||
*/
|
||||
public static function merge(DisplayVersionType|string $type, ?array $settings): array
|
||||
{
|
||||
return array_replace_recursive(self::defaults($type), $settings ?? []);
|
||||
$merged = array_replace_recursive(self::defaults($type), $settings ?? []);
|
||||
|
||||
$typeValue = $type instanceof DisplayVersionType ? $type->value : $type;
|
||||
|
||||
if ($typeValue === DisplayVersionType::B2in->value) {
|
||||
return self::normalizeB2inBooleans($merged);
|
||||
}
|
||||
|
||||
if ($typeValue === DisplayVersionType::Offers->value && array_key_exists('loop', $merged)) {
|
||||
$merged['loop'] = self::toBool($merged['loop']);
|
||||
}
|
||||
|
||||
return $merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $settings
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function normalizeB2inBooleans(array $settings): array
|
||||
{
|
||||
foreach (self::B2IN_BOOLEAN_KEYS as $key) {
|
||||
if (array_key_exists($key, $settings)) {
|
||||
$settings[$key] = self::toBool($settings[$key]);
|
||||
}
|
||||
}
|
||||
|
||||
return $settings;
|
||||
}
|
||||
|
||||
private static function toBool(mixed $value): bool
|
||||
{
|
||||
if (is_bool($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
$filtered = filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
|
||||
|
||||
return $filtered ?? (bool) $value;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -63,8 +63,14 @@
|
|||
"post-update-cmd": [
|
||||
"@php artisan vendor:publish --tag=laravel-assets --ansi --force",
|
||||
"@php artisan livewire:publish --assets --ansi",
|
||||
"@php artisan flux:publish-assets --ansi --force",
|
||||
"@php artisan boost:update --ansi || true"
|
||||
],
|
||||
"post-install-cmd": [
|
||||
"@php artisan vendor:publish --tag=laravel-assets --ansi --force",
|
||||
"@php artisan livewire:publish --assets --ansi",
|
||||
"@php artisan flux:publish-assets --ansi --force"
|
||||
],
|
||||
"post-root-package-install": [
|
||||
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\""
|
||||
],
|
||||
|
|
|
|||
274
dev/displays-11-05-2026/02-deployment-live-29-05-2026.md
Normal file
274
dev/displays-11-05-2026/02-deployment-live-29-05-2026.md
Normal file
|
|
@ -0,0 +1,274 @@
|
|||
# Display CMS – Deployment auf Live (29.05.2026)
|
||||
|
||||
Kurze Übersicht der Änderungen aus Commit `6c6d683` und was beim Live-Deploy zu beachten ist.
|
||||
|
||||
---
|
||||
|
||||
## Was wurde geändert?
|
||||
|
||||
### Mediathek
|
||||
- Videos zeigen ein **Vorschaubild** (Poster-Frame) statt nur eines Icons.
|
||||
- Poster werden beim Upload automatisch erzeugt (`ffmpeg`).
|
||||
- Neuer Artisan-Command zum Nachgenerieren bestehender Videos: `display-media:generate-video-thumbnails`
|
||||
- Neue Kategorie **„Sonstiges“** für B2in-Medien.
|
||||
|
||||
### B2in-Display-Modul
|
||||
- Media-Picker zeigt **alle Medien** (Bild + Video); der Typ wird aus der Datei abgeleitet.
|
||||
- **Marke** (ehem. Header): Logo und Claim frei in Ecken positionierbar; separate Schalter für Anzeige.
|
||||
- **Footer** global ein-/ausblendbar; unten positionierte Markenelemente nur ohne Footer.
|
||||
- Kleine **Thumbnail-Vorschau** vor allen Medien-URL-Feldern im Editor.
|
||||
|
||||
### Video-Playlist-Modul
|
||||
- Video-Vorschaubilder in der Übersicht.
|
||||
- Feld „Titel“ heißt jetzt **„Name“** (intern, nicht auf dem Display).
|
||||
- Vorschau im Modal zeigt bei neuen Einträgen kein falsches Video mehr.
|
||||
|
||||
### Angebote-Modul
|
||||
- **Kein Slide-Typ** mehr – einheitliches Detail-Layout.
|
||||
- Jeder Inhaltsblock (Logo, Badge, Texte, Preis, QR, Kontakt …) ist **pro Angebot** ein-/ausblendbar.
|
||||
- Logo und Brand-Text liegen **pro Slide**, nicht mehr in den Modul-Meta-Einstellungen.
|
||||
- Meta-Einstellungen: nur noch Endlosschleife, Transition, Dauer.
|
||||
- **Streichpreis**: Originalpreis rot durchgestrichen (optional).
|
||||
|
||||
### Display-Player (`public/_cabinet/display/index.html`)
|
||||
- B2in: Marke/Footer-Einstellungen werden ausgespielt.
|
||||
- Angebote: dynamisches Layout, Streichpreis, Show-Flags.
|
||||
- **Bugfix**: Endlosschleife bei leeren Modulen gestoppt (kein CPU-Loop mehr).
|
||||
|
||||
### Admin-Portal (allgemein)
|
||||
- **Script-Reihenfolge** korrigiert: `@livewireScripts` vor `@fluxScripts` (behebt `fluxModal`-Fehler).
|
||||
- Stale Datei `public/flux/flux.js` **entfernt** – Flux wird **nicht** statisch aus `public/` geladen (siehe Abschnitt „Flux UI auf Live“).
|
||||
- Livewire-Modal-Crash beim Aktualisieren von Inhalten behoben.
|
||||
|
||||
---
|
||||
|
||||
## Flux UI auf Live (wichtig – Switcher, Modals, Dropdowns)
|
||||
|
||||
### Diagnose (portal.b2in.eu)
|
||||
|
||||
```bash
|
||||
curl -I "https://portal.b2in.eu/flux/flux.min.js?id=test"
|
||||
# Aktuell: HTTP 404 ← Browser führt das Script NICHT aus → Switcher/Modals kaputt
|
||||
|
||||
curl -I "https://portal.b2in.eu/vendor/livewire/livewire.min.js?id=test"
|
||||
# HTTP 200 ← Livewire funktioniert, weil Assets statisch unter public/vendor/livewire/ liegen
|
||||
```
|
||||
|
||||
**Ursache:** Flux wird auf Live per Laravel-Route ausgeliefert, liefert dort aber **HTTP 404** (trotz JS-Inhalt). Livewire nutzt veröffentlichte Dateien in `public/vendor/livewire/` → **HTTP 200**.
|
||||
|
||||
**Symptome:** Modals öffnen nicht, Switcher reagieren nicht, Konsole zeigt `ReferenceError: Can't find variable: fluxModal`.
|
||||
|
||||
### Fix auf dem Live-Server
|
||||
|
||||
Flux-Assets wie Livewire statisch veröffentlichen:
|
||||
|
||||
```bash
|
||||
git pull
|
||||
composer install --no-dev --optimize-autoloader
|
||||
# ↑ führt flux:publish-assets automatisch aus (composer.json post-install-cmd)
|
||||
|
||||
# Falls nötig manuell:
|
||||
php artisan flux:publish-assets --force
|
||||
|
||||
php artisan route:clear
|
||||
php artisan config:cache
|
||||
php artisan view:clear
|
||||
```
|
||||
|
||||
**auth.json** muss auf Live existieren (Flux Pro) – die Datei liegt nicht in Git.
|
||||
|
||||
Der Command kopiert aktuelle Dateien aus `vendor/livewire/flux-pro/dist/` nach `public/flux/`. Nginx liefert sie dann direkt mit **HTTP 200** aus (wie bei Livewire).
|
||||
|
||||
### Prüfen ob Flux korrekt läuft
|
||||
|
||||
```bash
|
||||
curl -I "https://portal.b2in.eu/flux/flux.min.js?id=test"
|
||||
# Erwartung: HTTP 200
|
||||
# Kein x-fastcgi-cache Header (dann kommt es aus PHP statt statisch)
|
||||
```
|
||||
|
||||
Im Admin-Portal → DevTools → Netzwerk:
|
||||
|
||||
1. `vendor/livewire/livewire.min.js` → **200**
|
||||
2. `flux/flux.min.js` → **200** (nicht 404!)
|
||||
3. `livewire.js` lädt **vor** `flux.min.js`
|
||||
4. Hard-Reload (`Cmd+Shift+R` / `Ctrl+Shift+R`)
|
||||
|
||||
### Wichtig
|
||||
|
||||
| ❌ Falsch | ✅ Richtig |
|
||||
|---|---|
|
||||
| Alte `public/flux/flux.js` aus altem Backup zurückkopieren | `php artisan flux:publish-assets --force` |
|
||||
| `public/flux/` löschen ohne neu zu publishen | Danach immer `flux:publish-assets` ausführen |
|
||||
| Nur `route:clear` erwarten, dass es reicht | Statische Assets publishen (wie bei Livewire) |
|
||||
|
||||
---
|
||||
|
||||
## Live-Deploy Checkliste
|
||||
|
||||
### 1. Code deployen
|
||||
|
||||
```bash
|
||||
git pull
|
||||
composer install --no-dev --optimize-autoloader
|
||||
```
|
||||
|
||||
`composer install` veröffentlicht dabei automatisch Livewire- und Flux-Assets nach `public/` (`post-install-cmd`). Falls der Deploy-Prozess Composer-Skripte mit `--no-scripts` deaktiviert, danach zwingend manuell ausführen:
|
||||
|
||||
```bash
|
||||
php artisan livewire:publish --assets
|
||||
php artisan flux:publish-assets --force
|
||||
```
|
||||
|
||||
**Keine neuen Migrationen** in diesem Release. Die Spalte `display_media.thumbnail_path` existiert bereits seit der Mediathek-Migration (`2026_03_26_*`).
|
||||
|
||||
### 2. FFmpeg installieren (Pflicht für Video-Poster)
|
||||
|
||||
Video-Poster werden serverseitig per `ffmpeg` erzeugt (`DisplayMediaService`, Artisan-Command). Ohne FFmpeg funktionieren Uploads weiterhin, aber **keine Video-Vorschaubilder** (weder in der Mediathek noch im Editor).
|
||||
|
||||
#### Dev Container (lokal)
|
||||
|
||||
Im Dev Container ist `ffmpeg` bereits vorinstalliert. Prüfen:
|
||||
|
||||
```bash
|
||||
ffmpeg -version
|
||||
# Erwartung: /usr/bin/ffmpeg
|
||||
```
|
||||
|
||||
#### Ubuntu / Debian (Live-Server)
|
||||
|
||||
```bash
|
||||
sudo apt update
|
||||
sudo apt install -y ffmpeg
|
||||
ffmpeg -version
|
||||
```
|
||||
|
||||
#### CentOS / RHEL / AlmaLinux
|
||||
|
||||
```bash
|
||||
sudo dnf install -y ffmpeg
|
||||
# Alternativ auf älteren Systemen:
|
||||
sudo yum install -y ffmpeg
|
||||
```
|
||||
|
||||
Falls `ffmpeg` im Standard-Repo fehlt, ggf. **RPM Fusion** aktivieren.
|
||||
|
||||
#### macOS (lokal ohne Dev Container)
|
||||
|
||||
```bash
|
||||
brew install ffmpeg
|
||||
```
|
||||
|
||||
#### Wichtig: FFmpeg muss für PHP erreichbar sein
|
||||
|
||||
Nicht nur in der SSH-Session, sondern auch für den **Webserver-User** (PHP-FPM), da Poster sowohl beim Artisan-Command als auch beim Upload in der Mediathek erzeugt werden:
|
||||
|
||||
```bash
|
||||
# Prüfen (User ggf. anpassen: www-data, nginx, apache …)
|
||||
sudo -u www-data which ffmpeg
|
||||
sudo -u www-data ffmpeg -version
|
||||
```
|
||||
|
||||
Wenn `which ffmpeg` für den Webserver-User leer ist, `ffmpeg` systemweit installieren oder sicherstellen, dass `/usr/bin` im PATH von PHP-FPM liegt. Der Code ruft `ffmpeg` direkt auf – ein separater `.env`-Key ist nicht nötig.
|
||||
|
||||
### 3. Bestehende Videos: Poster nachgenerieren
|
||||
|
||||
Einmalig nach dem Deploy (oder bei Bedarf erneut mit `--force`):
|
||||
|
||||
```bash
|
||||
php artisan display-media:generate-video-thumbnails
|
||||
```
|
||||
|
||||
Thumbnails landen auf dem `public`-Disk neben der Videodatei (`…-poster.jpg`).
|
||||
|
||||
### 4. Laravel-Caches leeren
|
||||
|
||||
```bash
|
||||
php artisan route:clear # wichtig für Flux-Routen auf Portal-Domain
|
||||
php artisan config:cache
|
||||
php artisan view:clear
|
||||
```
|
||||
|
||||
> **Hinweis:** `route:cache` erst verwenden, wenn `/flux/flux.min.js` auf Live mit HTTP 200 antwortet. Siehe Abschnitt „Flux UI auf Live“.
|
||||
|
||||
### 5. Storage prüfen
|
||||
|
||||
```bash
|
||||
php artisan storage:link # nur falls noch nicht vorhanden
|
||||
```
|
||||
|
||||
Schreibrechte auf `storage/app/public/display-media/` müssen für den Webserver/PHP-User gesetzt sein (Poster-Erzeugung).
|
||||
|
||||
### 6. Umgebungsvariablen
|
||||
|
||||
In `.env` auf Live prüfen:
|
||||
|
||||
| Variable | Live-Beispiel | Hinweis |
|
||||
|---|---|---|
|
||||
| `DISPLAY_PLAYER_URL` | `https://cabinet.b2in.eu/display` | Player-URL für Admin-Vorschau und Links |
|
||||
| `DISPLAY_DOMAIN` | `b2in.eu` | Fallback in `config/display.php` |
|
||||
| `FILESYSTEM_DISK` | `public` oder S3 | Poster werden auf dem Media-Disk gespeichert |
|
||||
|
||||
Keine neuen Pflicht-ENV-Keys in diesem Release.
|
||||
|
||||
### 7. Frontend-Assets
|
||||
|
||||
Für dieses Release reichen **keine** separaten npm-Builds – Änderungen liegen in Blade-Templates, PHP und der statischen Player-HTML-Datei.
|
||||
|
||||
Falls euer Deploy-Prozess generell Assets baut:
|
||||
|
||||
```bash
|
||||
npm ci
|
||||
npm run build:admin
|
||||
```
|
||||
|
||||
### 8. Display-Player in der Praxis (Cabinet-Geräte)
|
||||
|
||||
- Die Player-Datei `public/_cabinet/display/index.html` ist **statisch** und wird mit dem Deploy ausgeliefert.
|
||||
- Geräte laden die Config alle **60 Sekunden** neu; nach Deploy spätestens innerhalb einer Minute die neue Player-Logik.
|
||||
- Zur Sicherheit: einmal **Hard-Reload** am Display-Browser oder kurz warten (Player hat zusätzlich 6h-Reload und Datei-Change-Erkennung).
|
||||
- **Cache-Control** für Preview-Routen ist auf `no-cache` gesetzt – betrifft Admin-Vorschau, nicht zwingend CDN vor `/display`.
|
||||
|
||||
### 9. Nach dem Deploy testen
|
||||
|
||||
Kurz im Admin-Portal (`portal.b2in.eu`):
|
||||
|
||||
1. **Mediathek** – Video zeigt Poster? Neues Video hochladen → Poster erscheint?
|
||||
2. **B2in-Modul** – Footer aus, Logo-Ecke unten, Claim-Position, Vorschau im iframe.
|
||||
3. **Angebote** – Slide bearbeiten, Blöcke ein/aus, Streichpreis, Logo pro Slide.
|
||||
4. **Leeres Modul** – Editor öffnen ohne Inhalte → kein Browser-Crash, kein Endlos-Reload in der Vorschau.
|
||||
5. **Browser-Konsole** – keine `fluxModal`- oder Livewire-Morph-Fehler mehr.
|
||||
6. **Flux-Assets** – `/flux/flux.min.js` → 200; Switcher und Modals reagieren normal.
|
||||
|
||||
---
|
||||
|
||||
## Rückwärtskompatibilität
|
||||
|
||||
- **Bestehende Angebots-Slides** ohne `show_*`-Flags rendern weiter wie bisher (Fallback: Block sichtbar, wenn Inhalt vorhanden).
|
||||
- **B2in-Module** mit alten Einstellungen behalten ihre Defaults (Footer an, Logo oben links).
|
||||
- **Offers Meta-Einstellungen** (Logo, Brand-Text, Footer) in alten Modul-`settings` werden ignoriert – Branding liegt jetzt pro Slide. Bestehende Slides ohne Logo-Felder nutzen weiter die Player-Defaults.
|
||||
|
||||
---
|
||||
|
||||
## Bekannte Abhängigkeiten
|
||||
|
||||
| Abhängigkeit | Zweck | Ohne |
|
||||
|---|---|---|
|
||||
| `ffmpeg` | Video-Poster | Upload ok, kein Thumbnail |
|
||||
| `public`-Storage | Medien + Poster | Upload schlägt fehl |
|
||||
| Portal-Domain | Admin + API | Editor/Vorschau |
|
||||
| Display-Domain | Player-Ausspielung | Cabinet-Geräte |
|
||||
|
||||
---
|
||||
|
||||
## Rollback
|
||||
|
||||
Bei Problemen: auf den vorherigen Git-Stand zurück (`git revert` oder Deploy des letzten Releases).
|
||||
|
||||
- Keine DB-Migration rückgängig machen nötig.
|
||||
- Bereits erzeugte Poster-Dateien können liegen bleiben (harmlos).
|
||||
- `public/flux/` nicht manuell aus altem Backup wiederherstellen – stattdessen `php artisan flux:publish-assets --force`.
|
||||
|
||||
---
|
||||
|
||||
*Stand: 29.05.2026 · Commit `6c6d683`*
|
||||
|
|
@ -189,8 +189,6 @@
|
|||
font-size: 1.8vh; font-weight: 300; color: rgba(255,255,255,0.7);
|
||||
line-height: 1.4;
|
||||
}
|
||||
/* Without footer the text reclaims the footer's space at the bottom */
|
||||
.b2in-layer.no-footer .b2in-text { padding-bottom: 4vh; }
|
||||
|
||||
/* B2in Footer */
|
||||
.b2in-footer {
|
||||
|
|
@ -237,6 +235,13 @@
|
|||
}
|
||||
.b2in-layer[data-theme="light"] .b2in-footer-name { color: rgba(43,63,81,0.5); }
|
||||
|
||||
/* Without footer the text reclaims the footer's space and loses the bottom scrim */
|
||||
.b2in-layer.no-footer .b2in-text,
|
||||
.b2in-layer.no-footer[data-theme="light"] .b2in-text {
|
||||
padding-bottom: 4vh;
|
||||
background: none;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
OFFERS TYPE (DOM-based slides)
|
||||
======================================== */
|
||||
|
|
@ -270,6 +275,9 @@
|
|||
transform-origin: top left;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.offer-slide--no-header { grid-template-rows: 1fr auto; }
|
||||
.offer-slide--no-bottom { grid-template-rows: auto 1fr; }
|
||||
.offer-slide--no-header.offer-slide--no-bottom { grid-template-rows: 1fr; }
|
||||
|
||||
/* Offer Header */
|
||||
.offer-header {
|
||||
|
|
@ -309,6 +317,13 @@
|
|||
display: grid; grid-template-columns: 1fr 300px; gap: 24px;
|
||||
align-items: stretch;
|
||||
}
|
||||
.offer-bottom--info-only,
|
||||
.offer-bottom--qr-only {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.offer-bottom--compact {
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
/* Info Box */
|
||||
.offer-info {
|
||||
|
|
@ -317,6 +332,13 @@
|
|||
border: 1px solid #e8e8e8; border-radius: 24px;
|
||||
padding: 28px; min-height: 340px;
|
||||
}
|
||||
.offer-info--compact {
|
||||
min-height: 0;
|
||||
padding: 24px 28px;
|
||||
}
|
||||
.offer-info--title-only {
|
||||
padding: 22px 28px;
|
||||
}
|
||||
.offer-info-content { flex: 1; }
|
||||
.offer-eyebrow {
|
||||
font-size: 18px; color: #737373; letter-spacing: 0.14em;
|
||||
|
|
@ -540,12 +562,96 @@ function escapeHtml(value) {
|
|||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function detectDisplayPortalBaseUrl() {
|
||||
const { protocol, hostname, port } = window.location;
|
||||
const portSuffix = port ? `:${port}` : '';
|
||||
|
||||
if (hostname === 'cabinet.b2in.eu') {
|
||||
return 'https://portal.b2in.eu';
|
||||
}
|
||||
|
||||
if (hostname.startsWith('cabinet.')) {
|
||||
return `${protocol}//${hostname.replace(/^cabinet\./, 'portal.')}${portSuffix}`;
|
||||
}
|
||||
|
||||
return window.location.origin;
|
||||
}
|
||||
|
||||
const DISPLAY_PORTAL_BASE_URL = detectDisplayPortalBaseUrl();
|
||||
|
||||
function resolveDisplayUrl(url) {
|
||||
if (!url) return '';
|
||||
if (url.startsWith('http://') || url.startsWith('https://')) return url;
|
||||
if (url.startsWith('/_cabinet/')) return url;
|
||||
if (url.startsWith('/') && DISPLAY_PORTAL_BASE_URL !== window.location.origin) {
|
||||
return `${DISPLAY_PORTAL_BASE_URL}${url}`;
|
||||
}
|
||||
if (url.startsWith('/')) return url;
|
||||
if (url.startsWith('../assets/')) return `/_cabinet/assets/${url.slice('../assets/'.length)}`;
|
||||
if (!url.includes('/') && url.endsWith('.svg')) return `/_cabinet/assets/${url}`;
|
||||
if (url.startsWith('../')) return url;
|
||||
return `../${url}`;
|
||||
}
|
||||
|
||||
function normalizeQrUrl(url) {
|
||||
if (!url) return '';
|
||||
if (url.startsWith('http://') || url.startsWith('https://')) return url;
|
||||
return `https://${url}`;
|
||||
}
|
||||
|
||||
function qrCodeImageUrl(url, size = 300, margin = 8, extraParams = {}) {
|
||||
const normalizedUrl = normalizeQrUrl(url);
|
||||
if (!normalizedUrl) return '';
|
||||
|
||||
const params = new URLSearchParams({
|
||||
size: `${size}x${size}`,
|
||||
margin: String(margin),
|
||||
data: normalizedUrl,
|
||||
...extraParams,
|
||||
});
|
||||
|
||||
return `https://api.qrserver.com/v1/create-qr-code/?${params.toString()}`;
|
||||
}
|
||||
|
||||
function setQrImage(img, url, options = {}) {
|
||||
const baseSrc = options.imageUrl
|
||||
? resolveDisplayUrl(options.imageUrl)
|
||||
: qrCodeImageUrl(url, options.size || 300, options.margin || 8, options.params || {});
|
||||
if (!img || !baseSrc) return false;
|
||||
|
||||
const maxAttempts = options.maxAttempts || 3;
|
||||
let attempt = 0;
|
||||
|
||||
img.alt = options.alt || 'QR Code';
|
||||
img.loading = 'eager';
|
||||
img.decoding = 'async';
|
||||
img.referrerPolicy = 'no-referrer';
|
||||
img.style.visibility = 'hidden';
|
||||
|
||||
const load = () => {
|
||||
attempt++;
|
||||
const separator = baseSrc.includes('?') ? '&' : '?';
|
||||
img.src = `${baseSrc}${separator}cb=${Date.now()}-${attempt}`;
|
||||
};
|
||||
|
||||
img.onload = () => {
|
||||
img.style.visibility = 'visible';
|
||||
};
|
||||
|
||||
img.onerror = () => {
|
||||
if (attempt < maxAttempts) {
|
||||
setTimeout(load, attempt * 1000);
|
||||
return;
|
||||
}
|
||||
|
||||
img.style.visibility = 'hidden';
|
||||
};
|
||||
|
||||
load();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cabinet Display Player
|
||||
*
|
||||
|
|
@ -563,7 +669,7 @@ class DisplayPlayer {
|
|||
this.displayId = this.detectDisplayId();
|
||||
|
||||
// API
|
||||
this.BASE_URL = this.detectBaseUrl();
|
||||
this.BASE_URL = DISPLAY_PORTAL_BASE_URL;
|
||||
this.API_CONFIG = this.detectConfigUrl();
|
||||
this.API_CHECK = this.detectCheckUrl();
|
||||
this.API_OVERVIEW = `${this.BASE_URL}/api/display/overview`;
|
||||
|
|
@ -689,11 +795,7 @@ class DisplayPlayer {
|
|||
}
|
||||
|
||||
detectBaseUrl() {
|
||||
const hostname = window.location.hostname;
|
||||
if (hostname === 'cabinet.b2in.eu') {
|
||||
return 'https://portal.b2in.eu';
|
||||
}
|
||||
return window.location.origin;
|
||||
return DISPLAY_PORTAL_BASE_URL;
|
||||
}
|
||||
|
||||
// ========================================
|
||||
|
|
@ -706,27 +808,25 @@ class DisplayPlayer {
|
|||
try {
|
||||
if (!this.displayId && !this.previewToken && !this.moduleId) {
|
||||
await this.fetchOverview();
|
||||
return;
|
||||
}
|
||||
|
||||
} else {
|
||||
await this.fetchConfig();
|
||||
|
||||
if (this.playlist.length === 0) {
|
||||
this.showError('Keine Versionen zugewiesen');
|
||||
this.scheduleRetry();
|
||||
return;
|
||||
}
|
||||
|
||||
} else {
|
||||
this.hideLoading();
|
||||
this.startPlaylist();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Display] Init failed:', error);
|
||||
this.showError('Konfiguration konnte nicht geladen werden');
|
||||
this.scheduleRetry();
|
||||
}
|
||||
|
||||
this.startPolling();
|
||||
this.recordFileVersion();
|
||||
this.startConfigPolling();
|
||||
this.startFileVersionPolling();
|
||||
this.scheduleAutoReload();
|
||||
}
|
||||
|
||||
|
|
@ -759,7 +859,7 @@ class DisplayPlayer {
|
|||
this.renderOverview(data.displays || []);
|
||||
}
|
||||
|
||||
startPolling() {
|
||||
startConfigPolling() {
|
||||
if (!this.API_CHECK) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -771,7 +871,7 @@ class DisplayPlayer {
|
|||
|
||||
const data = await response.json();
|
||||
if (data.updated_at !== this.cachedTimestamp) {
|
||||
console.log('[Display] Change detected, reloading...');
|
||||
console.log('[Display] Playlist geändert – Seite wird neu geladen');
|
||||
location.reload();
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
@ -784,17 +884,33 @@ class DisplayPlayer {
|
|||
}
|
||||
}
|
||||
}, this.POLL_INTERVAL);
|
||||
}
|
||||
|
||||
startFileVersionPolling() {
|
||||
this.recordFileVersion();
|
||||
setInterval(() => this.checkFileVersion(), this.FILE_CHECK_INTERVAL);
|
||||
}
|
||||
|
||||
playerFileCheckUrl() {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('_player_filecheck', '1');
|
||||
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
readPlayerFileVersion(response) {
|
||||
return response.headers.get('ETag')
|
||||
|| response.headers.get('Last-Modified')
|
||||
|| response.headers.get('Content-Length');
|
||||
}
|
||||
|
||||
async recordFileVersion() {
|
||||
try {
|
||||
const response = await fetch(window.location.href, {
|
||||
const response = await fetch(this.playerFileCheckUrl(), {
|
||||
method: 'HEAD',
|
||||
cache: 'no-store',
|
||||
});
|
||||
this.fileVersion = response.headers.get('ETag') || response.headers.get('Last-Modified');
|
||||
this.fileVersion = this.readPlayerFileVersion(response);
|
||||
} catch (e) {
|
||||
// Ignorieren – wird beim nächsten Check erneut versucht
|
||||
}
|
||||
|
|
@ -802,14 +918,23 @@ class DisplayPlayer {
|
|||
|
||||
async checkFileVersion() {
|
||||
try {
|
||||
const response = await fetch(window.location.href, {
|
||||
const response = await fetch(this.playerFileCheckUrl(), {
|
||||
method: 'HEAD',
|
||||
cache: 'no-store',
|
||||
});
|
||||
const version = response.headers.get('ETag') || response.headers.get('Last-Modified');
|
||||
const version = this.readPlayerFileVersion(response);
|
||||
|
||||
if (this.fileVersion && version && version !== this.fileVersion) {
|
||||
console.log('[Display] Datei geändert – Seite wird neu geladen');
|
||||
if (!version) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.fileVersion === null) {
|
||||
this.fileVersion = version;
|
||||
return;
|
||||
}
|
||||
|
||||
if (version !== this.fileVersion) {
|
||||
console.log('[Display] Player-Datei geändert – Seite wird neu geladen');
|
||||
location.reload();
|
||||
}
|
||||
} catch (e) {
|
||||
|
|
@ -1157,8 +1282,7 @@ class VideoDisplayRenderer {
|
|||
headline.textContent = content.headline || '';
|
||||
subline.textContent = content.subline || '';
|
||||
|
||||
if (content.url) {
|
||||
qrImg.src = `https://api.qrserver.com/v1/create-qr-code/?size=300x300&margin=10&data=${encodeURIComponent(content.url)}`;
|
||||
if ((content.url || content.qr_image_url) && setQrImage(qrImg, content.url, { imageUrl: content.qr_image_url, margin: 10, alt: 'QR' })) {
|
||||
qrContainer.style.display = 'flex';
|
||||
} else {
|
||||
qrContainer.style.display = 'none';
|
||||
|
|
@ -1166,12 +1290,7 @@ 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}`;
|
||||
return resolveDisplayUrl(src);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
|
|
@ -1218,7 +1337,7 @@ class B2inRenderer {
|
|||
layer.className = 'version-layer b2in-layer active' + (this.settings.show_footer === false ? ' no-footer' : '');
|
||||
layer.setAttribute('data-theme', this.theme);
|
||||
|
||||
const headerLogoUrl = this.resolveUrl(this.settings.header_logo_url || '../assets/b2in-logo-positive.svg');
|
||||
const headerLogoUrl = this.resolveUrl(this.settings.header_logo_url || '/_cabinet/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 || '';
|
||||
|
|
@ -1227,6 +1346,7 @@ class B2inRenderer {
|
|||
? `<span class="b2in-footer-name">${escapeHtml(footerPrefix ? `${footerPrefix} ${footerName}` : footerName)}</span>`
|
||||
: '';
|
||||
const qrUrl = normalizeQrUrl(this.settings.qr_url || footerUrl || 'b2in.eu');
|
||||
const qrImageUrl = this.settings.qr_image_url || '';
|
||||
|
||||
// Footer visibility, brand element visibility + corner positioning
|
||||
const showFooter = this.settings.show_footer !== false;
|
||||
|
|
@ -1269,7 +1389,7 @@ class B2inRenderer {
|
|||
${footerNameHtml}
|
||||
</div>
|
||||
<div class="b2in-footer-qr">
|
||||
<img src="https://api.qrserver.com/v1/create-qr-code/?size=200x200&margin=5&data=${encodeURIComponent(qrUrl)}" alt="QR">
|
||||
<img alt="QR">
|
||||
</div>
|
||||
</footer>`
|
||||
: '';
|
||||
|
|
@ -1300,6 +1420,11 @@ class B2inRenderer {
|
|||
this.headline = layer.querySelector('#b2in-headline');
|
||||
this.subline = layer.querySelector('#b2in-subline');
|
||||
this.progress = layer.querySelector('#b2in-progress');
|
||||
|
||||
const footerQrImg = layer.querySelector('.b2in-footer-qr img');
|
||||
if (footerQrImg) {
|
||||
setQrImage(footerQrImg, qrUrl, { imageUrl: qrImageUrl, size: 200, margin: 5, alt: 'QR' });
|
||||
}
|
||||
}
|
||||
|
||||
start() {
|
||||
|
|
@ -1373,15 +1498,31 @@ class B2inRenderer {
|
|||
// Image
|
||||
const img = document.createElement('img');
|
||||
img.style.cssText = 'width:100%;height:100%;object-fit:cover;';
|
||||
|
||||
let handled = false;
|
||||
const finish = () => {
|
||||
if (handled || this.destroyed) return;
|
||||
handled = true;
|
||||
clearTimeout(this.itemTimer);
|
||||
this.showItem(index + 1);
|
||||
};
|
||||
|
||||
// Broken image (e.g. deleted media, 404): skip after a short grace
|
||||
// period instead of showing a black frame for the full duration.
|
||||
// The delay also prevents a rapid loop when every image is broken.
|
||||
img.onerror = () => {
|
||||
console.warn(`[B2in] Image error: ${item.media_url}`);
|
||||
this.hideProgress();
|
||||
setTimeout(finish, 2000);
|
||||
};
|
||||
|
||||
img.src = this.resolveUrl(item.media_url);
|
||||
incoming.appendChild(img);
|
||||
|
||||
const duration = (item.duration_seconds || this.settings.default_image_duration || 10) * 1000;
|
||||
this.showProgress(duration);
|
||||
|
||||
this.itemTimer = setTimeout(() => {
|
||||
if (!this.destroyed) this.showItem(index + 1);
|
||||
}, duration);
|
||||
this.itemTimer = setTimeout(finish, duration);
|
||||
}
|
||||
|
||||
// Crossfade
|
||||
|
|
@ -1406,11 +1547,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}`;
|
||||
return resolveDisplayUrl(url);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
|
|
@ -1512,12 +1649,19 @@ class OffersRenderer {
|
|||
qr: (slide.show_qr ?? has(slide.qr_url)) && has(qrUrl),
|
||||
contact: (slide.show_contact ?? has(slide.contact)) && has(contactText),
|
||||
};
|
||||
const showInfo = has(slide.title) || show.eyebrow || show.subline || show.bullets || show.price || show.disclaimer;
|
||||
const showQrBox = show.qr || show.contact;
|
||||
const showBottom = showInfo || showQrBox;
|
||||
const infoBlockCount = [has(slide.title), show.eyebrow, show.subline, show.bullets, show.price, show.disclaimer]
|
||||
.filter(Boolean).length;
|
||||
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'offers-slide-container';
|
||||
|
||||
const article = document.createElement('article');
|
||||
article.className = 'offer-slide';
|
||||
if (!show.logo) article.classList.add('offer-slide--no-header');
|
||||
if (!showBottom) article.classList.add('offer-slide--no-bottom');
|
||||
|
||||
// --- HEADER (Logo & Marke) – per slide, toggleable ---
|
||||
if (show.logo) {
|
||||
|
|
@ -1549,9 +1693,6 @@ class OffersRenderer {
|
|||
}
|
||||
|
||||
article.appendChild(header);
|
||||
} else {
|
||||
// Without the header row, let the hero stay the flexible middle row.
|
||||
article.style.gridTemplateRows = '1fr auto';
|
||||
}
|
||||
|
||||
// --- HERO ---
|
||||
|
|
@ -1571,13 +1712,25 @@ class OffersRenderer {
|
|||
|
||||
article.appendChild(hero);
|
||||
|
||||
if (!showBottom) {
|
||||
wrapper.appendChild(article);
|
||||
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
// --- BOTTOM: Info + QR ---
|
||||
const bottom = document.createElement('section');
|
||||
bottom.className = 'offer-bottom';
|
||||
if (showInfo && !showQrBox) bottom.classList.add('offer-bottom--info-only');
|
||||
if (!showInfo && showQrBox) bottom.classList.add('offer-bottom--qr-only');
|
||||
if (showInfo && infoBlockCount <= 3 && !show.price && !show.disclaimer) bottom.classList.add('offer-bottom--compact');
|
||||
|
||||
// Info
|
||||
if (showInfo) {
|
||||
const info = document.createElement('div');
|
||||
info.className = 'offer-info';
|
||||
if (infoBlockCount <= 3 && !show.price && !show.disclaimer) info.classList.add('offer-info--compact');
|
||||
if (infoBlockCount === 1 && has(slide.title)) info.classList.add('offer-info--title-only');
|
||||
|
||||
const infoContent = document.createElement('div');
|
||||
infoContent.className = 'offer-info-content';
|
||||
|
|
@ -1663,9 +1816,10 @@ class OffersRenderer {
|
|||
}
|
||||
|
||||
bottom.appendChild(info);
|
||||
}
|
||||
|
||||
// QR / Contact box – only when at least one of the two is enabled.
|
||||
if (show.qr || show.contact) {
|
||||
if (showQrBox) {
|
||||
const qrBox = document.createElement('aside');
|
||||
qrBox.className = 'offer-qr-box';
|
||||
|
||||
|
|
@ -1681,8 +1835,15 @@ class OffersRenderer {
|
|||
const qrWrapper = document.createElement('div');
|
||||
qrWrapper.className = 'offer-qr-wrapper';
|
||||
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(normalizeQrUrl(qrUrl))}`;
|
||||
qrImg.alt = 'QR Code';
|
||||
setQrImage(qrImg, qrUrl, {
|
||||
imageUrl: slide.qr_image_url || '',
|
||||
size: 300,
|
||||
margin: 8,
|
||||
params: {
|
||||
color: '000000',
|
||||
bgcolor: 'ffffff',
|
||||
},
|
||||
});
|
||||
qrWrapper.appendChild(qrImg);
|
||||
qrBox.appendChild(qrWrapper);
|
||||
}
|
||||
|
|
@ -1695,8 +1856,6 @@ class OffersRenderer {
|
|||
}
|
||||
|
||||
bottom.appendChild(qrBox);
|
||||
} else {
|
||||
bottom.style.gridTemplateColumns = '1fr';
|
||||
}
|
||||
|
||||
article.appendChild(bottom);
|
||||
|
|
@ -1754,11 +1913,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}`;
|
||||
return resolveDisplayUrl(url);
|
||||
}
|
||||
|
||||
escapeHtml(str) {
|
||||
|
|
|
|||
|
|
@ -118,5 +118,19 @@ textarea[data-flux-control]::placeholder {
|
|||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
/*
|
||||
* Flux switch thumb in dark mode: track uses data-checked on ui-switch.group, thumb uses
|
||||
* group-data-checked:translate-x-[15px]. If dark:translate-x-[2px] wins (stale views/CSS),
|
||||
* the knob stays left while the track looks "on". Attribute-based rules avoid that.
|
||||
*/
|
||||
ui-switch.group[data-checked] > span {
|
||||
--tw-translate-x: 15px;
|
||||
translate: var(--tw-translate-x) var(--tw-translate-y);
|
||||
}
|
||||
|
||||
ui-switch.group[data-checked] > span:where(:dir(rtl), [dir='rtl'], [dir='rtl'] *) {
|
||||
--tw-translate-x: -15px;
|
||||
}
|
||||
|
||||
/* Theme-specific button styles for Backend Portal */
|
||||
|
||||
|
|
|
|||
|
|
@ -11,12 +11,23 @@
|
|||
<div class="flex-1">
|
||||
<h3 class="font-semibold text-blue-900 dark:text-blue-100 mb-2">{{ __('Info-Tablet Steuerung') }}</h3>
|
||||
<div class="text-sm text-blue-800 dark:text-blue-200 space-y-1">
|
||||
<p>Das Info-Tablet im Schaufenster zeigt Store-Status, Öffnungszeiten und den nächsten freien Termin.</p>
|
||||
<p>• <strong>Automatisch:</strong> Offen/Geschlossen wird automatisch aus den hinterlegten Öffnungszeiten berechnet.</p>
|
||||
<p>• <strong>API-Endpunkt:</strong> <code class="px-2 py-0.5 bg-blue-100 dark:bg-blue-800 rounded">{{ url('/api/cabinet-tablet/status') }}</code></p>
|
||||
<p>• <strong>Website-Endpunkt:</strong> <code class="px-2 py-0.5 bg-blue-100 dark:bg-blue-800 rounded"><a target="_blank" href="https://cabinet.b2in.eu/info">https://cabinet.b2in.eu/info</a></code></p>
|
||||
<p>• <strong>Quick-Status-Endpunkt:</strong> <code class="px-2 py-0.5 bg-blue-100 dark:bg-blue-800 rounded"><a target="_blank" href="https://portal.b2in.eu/info/status?key={{ config('domains.cabinet_status_key') }}">https://portal.b2in.eu/info/status?key={{ config('domains.cabinet_status_key') }}</a></code></p>
|
||||
<p>• <strong>Quick-Status-Hinweis:</strong> Der Quick-Status-Endpunkt ist ein kleines Tool, um den Store-Status schnell zu ändern. Auf dem Mobiltelefon den Link öffen und dem Home-Screen hinzufügen.</p>
|
||||
<p>Das Info-Tablet im Schaufenster zeigt Store-Status, Öffnungszeiten und den nächsten freien
|
||||
Termin.</p>
|
||||
<p>• <strong>Automatisch:</strong> Offen/Geschlossen wird automatisch aus den hinterlegten
|
||||
Öffnungszeiten berechnet.</p>
|
||||
<p>• <strong>API-Endpunkt:</strong> <code
|
||||
class="px-2 py-0.5 bg-blue-100 dark:bg-blue-800 rounded">{{ url('/api/cabinet-tablet/status') }}</code>
|
||||
</p>
|
||||
<p>• <strong>Website-Endpunkt:</strong> <code
|
||||
class="px-2 py-0.5 bg-blue-100 dark:bg-blue-800 rounded"><a target="_blank"
|
||||
href="https://cabinet.b2in.eu/info">https://cabinet.b2in.eu/info</a></code></p>
|
||||
<p>• <strong>Quick-Status-Endpunkt:</strong> <code
|
||||
class="px-2 py-0.5 bg-blue-100 dark:bg-blue-800 rounded"><a target="_blank"
|
||||
href="https://portal.b2in.eu/info/status?key={{ config('domains.cabinet_status_key') }}">https://portal.b2in.eu/info/status?key={{ config('domains.cabinet_status_key') }}</a></code>
|
||||
</p>
|
||||
<p>• <strong>Quick-Status-Hinweis:</strong> Der Quick-Status-Endpunkt ist ein kleines Tool, um den
|
||||
Store-Status schnell zu ändern. Auf dem Mobiltelefon den Link öffen und dem Home-Screen
|
||||
hinzufügen.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -29,8 +40,7 @@
|
|||
</x-success-alert>
|
||||
@endif
|
||||
|
||||
<form wire:submit.prevent="save"
|
||||
x-data
|
||||
<form wire:submit.prevent="save" x-data
|
||||
x-on:submit.prevent="
|
||||
const hours = {};
|
||||
document.querySelectorAll('[data-hours-prop]').forEach(el => {
|
||||
|
|
@ -57,17 +67,25 @@
|
|||
@if ($storeStatus !== 'auto')
|
||||
@php
|
||||
$noticeColors = match ($storeStatus) {
|
||||
'notice' => 'bg-orange-50 dark:bg-orange-900/20 border border-orange-200 dark:border-orange-800',
|
||||
'notice'
|
||||
=> 'bg-orange-50 dark:bg-orange-900/20 border border-orange-200 dark:border-orange-800',
|
||||
'warning' => 'bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800',
|
||||
default => 'bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800',
|
||||
default
|
||||
=> 'bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800',
|
||||
};
|
||||
@endphp
|
||||
<div class="space-y-4 p-4 rounded-lg {{ $noticeColors }}">
|
||||
<flux:input wire:model="noticeHeadline" label="Headline" placeholder="z.B. Heute erst ab 11:00 Uhr" maxlength="40" />
|
||||
@error('noticeHeadline') <span class="text-red-600 text-sm">{{ $message }}</span> @enderror
|
||||
<flux:input wire:model="noticeHeadline" label="Headline"
|
||||
placeholder="z.B. Heute erst ab 11:00 Uhr" maxlength="40" />
|
||||
@error('noticeHeadline')
|
||||
<span class="text-red-600 text-sm">{{ $message }}</span>
|
||||
@enderror
|
||||
|
||||
<flux:input wire:model="noticeSubtext" label="Subtext (optional)" placeholder="z.B. Wegen eines Kundentermins öffnen wir heute später." maxlength="80" />
|
||||
@error('noticeSubtext') <span class="text-red-600 text-sm">{{ $message }}</span> @enderror
|
||||
<flux:input wire:model="noticeSubtext" label="Subtext (optional)"
|
||||
placeholder="z.B. Wegen eines Kundentermins öffnen wir heute später." maxlength="80" />
|
||||
@error('noticeSubtext')
|
||||
<span class="text-red-600 text-sm">{{ $message }}</span>
|
||||
@enderror
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
|
@ -76,30 +94,28 @@
|
|||
{{-- Öffnungszeiten --}}
|
||||
<flux:card>
|
||||
<flux:heading size="lg" class="mb-1">{{ __('Öffnungszeiten') }}</flux:heading>
|
||||
<p class="text-sm text-zinc-500 dark:text-zinc-400 mb-4">Felder leer lassen = Geschlossen. Der Status "Geöffnet/Geschlossen" wird automatisch aus diesen Zeiten berechnet.</p>
|
||||
<p class="text-sm text-zinc-500 dark:text-zinc-400 mb-4">Felder leer lassen = Geschlossen. Der Status
|
||||
"Geöffnet/Geschlossen" wird automatisch aus diesen Zeiten berechnet.</p>
|
||||
|
||||
<div class="space-y-3">
|
||||
@foreach([
|
||||
['label' => 'Montag', 'open' => 'hoursMondayOpen', 'close' => 'hoursMondayClose'],
|
||||
['label' => 'Dienstag', 'open' => 'hoursTuesdayOpen', 'close' => 'hoursTuesdayClose'],
|
||||
['label' => 'Mittwoch', 'open' => 'hoursWednesdayOpen', 'close' => 'hoursWednesdayClose'],
|
||||
['label' => 'Donnerstag', 'open' => 'hoursThursdayOpen', 'close' => 'hoursThursdayClose'],
|
||||
['label' => 'Freitag', 'open' => 'hoursFridayOpen', 'close' => 'hoursFridayClose'],
|
||||
['label' => 'Samstag', 'open' => 'hoursSaturdayOpen', 'close' => 'hoursSaturdayClose'],
|
||||
['label' => 'Sonntag', 'open' => 'hoursSundayOpen', 'close' => 'hoursSundayClose'],
|
||||
] as $row)
|
||||
@foreach ([['label' => 'Montag', 'open' => 'hoursMondayOpen', 'close' => 'hoursMondayClose'], ['label' => 'Dienstag', 'open' => 'hoursTuesdayOpen', 'close' => 'hoursTuesdayClose'], ['label' => 'Mittwoch', 'open' => 'hoursWednesdayOpen', 'close' => 'hoursWednesdayClose'], ['label' => 'Donnerstag', 'open' => 'hoursThursdayOpen', 'close' => 'hoursThursdayClose'], ['label' => 'Freitag', 'open' => 'hoursFridayOpen', 'close' => 'hoursFridayClose'], ['label' => 'Samstag', 'open' => 'hoursSaturdayOpen', 'close' => 'hoursSaturdayClose'], ['label' => 'Sonntag', 'open' => 'hoursSundayOpen', 'close' => 'hoursSundayClose']] as $row)
|
||||
@php
|
||||
$isClosedDay = empty($this->{$row['open']}) && empty($this->{$row['close']});
|
||||
@endphp
|
||||
<div class="grid grid-cols-[120px_1fr_1fr_80px] items-center gap-3 py-2 border-b border-zinc-100 dark:border-zinc-800 last:border-0">
|
||||
<span class="text-sm font-medium text-zinc-700 dark:text-zinc-300">{{ $row['label'] }}</span>
|
||||
<div
|
||||
class="grid grid-cols-[120px_1fr_1fr_80px] items-center gap-3 py-2 border-b border-zinc-100 dark:border-zinc-800 last:border-0">
|
||||
<span
|
||||
class="text-sm font-medium text-zinc-700 dark:text-zinc-300">{{ $row['label'] }}</span>
|
||||
<div data-hours-prop="{{ $row['open'] }}">
|
||||
<flux:time-picker wire:model.live="{{ $row['open'] }}" label="Öffnung" time-format="24-hour" interval="15" clearable placeholder="–" />
|
||||
<flux:time-picker wire:model.live="{{ $row['open'] }}" label="Öffnung"
|
||||
time-format="24-hour" interval="15" clearable placeholder="–" />
|
||||
</div>
|
||||
<div data-hours-prop="{{ $row['close'] }}">
|
||||
<flux:time-picker wire:model.live="{{ $row['close'] }}" label="Schluss" time-format="24-hour" interval="15" clearable placeholder="–" />
|
||||
<flux:time-picker wire:model.live="{{ $row['close'] }}" label="Schluss"
|
||||
time-format="24-hour" interval="15" clearable placeholder="–" />
|
||||
</div>
|
||||
<span class="text-xs text-center {{ $isClosedDay ? 'text-red-500 dark:text-red-400' : 'text-emerald-600 dark:text-emerald-400' }}">
|
||||
<span
|
||||
class="text-xs text-center {{ $isClosedDay ? 'text-red-500 dark:text-red-400' : 'text-emerald-600 dark:text-emerald-400' }}">
|
||||
{{ $isClosedDay ? 'Geschlossen' : 'Geöffnet' }}
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -147,10 +163,15 @@
|
|||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<flux:input wire:model="contactPhone" label="Telefon" placeholder="z.B. 0521 98620100" />
|
||||
@error('contactPhone') <span class="text-red-600 text-sm">{{ $message }}</span> @enderror
|
||||
@error('contactPhone')
|
||||
<span class="text-red-600 text-sm">{{ $message }}</span>
|
||||
@enderror
|
||||
|
||||
<flux:input wire:model="contactEmail" label="E-Mail" type="email" placeholder="z.B. info@cabinet-bielefeld.de" />
|
||||
@error('contactEmail') <span class="text-red-600 text-sm">{{ $message }}</span> @enderror
|
||||
<flux:input wire:model="contactEmail" label="E-Mail" type="email"
|
||||
placeholder="z.B. info@cabinet-bielefeld.de" />
|
||||
@error('contactEmail')
|
||||
<span class="text-red-600 text-sm">{{ $message }}</span>
|
||||
@enderror
|
||||
</div>
|
||||
</flux:card>
|
||||
|
||||
|
|
|
|||
|
|
@ -88,10 +88,17 @@ $handleUploads = function () {
|
|||
$service = app(DisplayMediaService::class);
|
||||
$count = 0;
|
||||
|
||||
try {
|
||||
foreach ($this->uploads as $file) {
|
||||
$service->storeUpload($file);
|
||||
$count++;
|
||||
}
|
||||
} catch (\RuntimeException $exception) {
|
||||
$this->uploads = [];
|
||||
$this->addError('uploads', $exception->getMessage());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->uploads = [];
|
||||
|
||||
|
|
|
|||
|
|
@ -126,6 +126,16 @@
|
|||
<flux:input wire:model.live.debounce.500ms="videoFilename" label="Video-Pfad / URL" placeholder="Wird automatisch gesetzt oder manuell eingeben..."
|
||||
description="Über die Mediathek auswählen oder direkt Pfad/URL eingeben." class="flex-1" />
|
||||
</div>
|
||||
@if($formatStatus = $this->displayMediaFormatStatus($videoFilename))
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<flux:badge size="sm" :color="$formatStatus['color']">
|
||||
{{ __($formatStatus['label']) }}
|
||||
</flux:badge>
|
||||
<flux:text class="text-xs text-zinc-500 dark:text-zinc-400">
|
||||
{{ $formatStatus['description'] }}
|
||||
</flux:text>
|
||||
</div>
|
||||
@endif
|
||||
<flux:input wire:model="videoTitle" label="Name" placeholder="z.B. Herbst Kollektion 2025"
|
||||
description="Interner Name des Videos – wird nicht im Video eingeblendet." />
|
||||
<flux:input wire:model="videoPosition" type="number" min="0" max="100" label="Position (%)"
|
||||
|
|
@ -160,8 +170,18 @@
|
|||
<flux:badge size="sm" :color="$mediaType === 'video' ? 'purple' : 'sky'">
|
||||
{{ $mediaType === 'video' ? __('Video') : __('Bild') }}
|
||||
</flux:badge>
|
||||
@if($formatStatus = $this->displayMediaFormatStatus($mediaUrl))
|
||||
<flux:badge size="sm" :color="$formatStatus['color']">
|
||||
{{ __($formatStatus['label']) }}
|
||||
</flux:badge>
|
||||
@endif
|
||||
<flux:text class="text-xs text-zinc-400">{{ __('wird automatisch aus dem gewählten Medium erkannt') }}</flux:text>
|
||||
</div>
|
||||
@if($formatStatus = $this->displayMediaFormatStatus($mediaUrl))
|
||||
<flux:text class="text-xs text-zinc-500 dark:text-zinc-400">
|
||||
{{ $formatStatus['description'] }}
|
||||
</flux:text>
|
||||
@endif
|
||||
<flux:select wire:model="mediaCategory" label="Kategorie">
|
||||
<option value="immobilien">Immobilien</option>
|
||||
<option value="moebel">Möbel</option>
|
||||
|
|
|
|||
|
|
@ -79,6 +79,14 @@
|
|||
icon="pencil">
|
||||
</flux:button>
|
||||
|
||||
<flux:button wire:click="copyVersion({{ $version->id }})"
|
||||
wire:confirm="Möchten Sie dieses Modul wirklich kopieren? Es wird eine Kopie mit allen Inhalten angelegt."
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
icon="document-duplicate"
|
||||
title="{{ __('Modul kopieren') }}">
|
||||
</flux:button>
|
||||
|
||||
<flux:button wire:click="deleteVersion({{ $version->id }})"
|
||||
wire:confirm="Möchten Sie dieses Modul wirklich löschen? Alle zugehörigen Inhalte werden ebenfalls gelöscht."
|
||||
size="sm"
|
||||
|
|
|
|||
|
|
@ -52,6 +52,11 @@
|
|||
<flux:badge color="{{ ($item->content['media_type'] ?? 'image') === 'video' ? 'purple' : 'sky' }}" size="sm">
|
||||
{{ ($item->content['media_type'] ?? 'image') === 'video' ? 'Video' : 'Bild' }}
|
||||
</flux:badge>
|
||||
@if($formatStatus = $this->displayMediaFormatStatus($item->content['media_url'] ?? ''))
|
||||
<flux:badge size="sm" :color="$formatStatus['color']">
|
||||
{{ __($formatStatus['label']) }}
|
||||
</flux:badge>
|
||||
@endif
|
||||
<flux:badge color="zinc" size="sm">
|
||||
{{ ucfirst($item->content['category'] ?? '–') }}
|
||||
</flux:badge>
|
||||
|
|
@ -62,6 +67,9 @@
|
|||
@if(($item->content['media_type'] ?? 'image') === 'image')
|
||||
<span>{{ $item->content['duration_seconds'] ?? 10 }}s</span>
|
||||
@endif
|
||||
@if($formatStatus = $this->displayMediaFormatStatus($item->content['media_url'] ?? ''))
|
||||
<span>{{ $formatStatus['description'] }}</span>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -27,13 +27,13 @@
|
|||
:key="'picker-b2in-header-logo-' . $context . '-' . $version->id" />
|
||||
<div class="flex items-end gap-3">
|
||||
<x-media-thumb :url="$settings['header_logo_url'] ?? ''" />
|
||||
<flux:input wire:model.live.debounce.500ms="settings.header_logo_url" label="Logo URL" placeholder="../assets/b2in-logo-positive.svg" class="flex-1" />
|
||||
<flux:input wire:model.live.debounce.500ms="settings.header_logo_url" label="Logo URL" placeholder="/_cabinet/assets/b2in-logo-positive.svg" class="flex-1" />
|
||||
</div>
|
||||
<flux:input wire:model="settings.header_claim" label="Claim" placeholder="Connecting Design & Property" />
|
||||
|
||||
<div class="flex flex-wrap gap-6">
|
||||
<flux:switch wire:model.live="settings.show_logo" label="Logo anzeigen" />
|
||||
<flux:switch wire:model.live="settings.show_claim" label="Claim anzeigen" />
|
||||
<flux:switch wire:model.live.boolean="settings.show_logo" label="Logo anzeigen" />
|
||||
<flux:switch wire:model.live.boolean="settings.show_claim" label="Claim anzeigen" />
|
||||
</div>
|
||||
|
||||
@if($showLogo)
|
||||
|
|
@ -63,7 +63,7 @@
|
|||
</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:switch wire:model.live="settings.show_footer" label="Footer anzeigen"
|
||||
<flux:switch wire:model.live.boolean="settings.show_footer" label="Footer anzeigen"
|
||||
description="Blendet die Fußzeile mit Domain, Name und QR-Code ein oder aus." />
|
||||
<flux:input wire:model="settings.footer_prefix" label="Footer-Präfix" placeholder="by" />
|
||||
<flux:input wire:model="settings.footer_name" label="Footer Name" placeholder="z.B. Marcel Scheibe" />
|
||||
|
|
@ -78,7 +78,7 @@
|
|||
</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" />
|
||||
<flux:checkbox wire:model.boolean="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">
|
||||
|
|
|
|||
|
|
@ -58,6 +58,12 @@
|
|||
<flux:badge size="sm" :color="$isMediaLibrarySource ? 'sky' : 'zinc'">
|
||||
{{ $isMediaLibrarySource ? __('Mediathek') : __('Legacy-Datei') }}
|
||||
</flux:badge>
|
||||
@if ($formatStatus = $this->displayMediaFormatStatus($videoSource))
|
||||
<flux:badge size="sm" :color="$formatStatus['color']">
|
||||
{{ __($formatStatus['label']) }}
|
||||
</flux:badge>
|
||||
<span>{{ $formatStatus['description'] }}</span>
|
||||
@endif
|
||||
<span class="truncate">{{ $videoSource ?: '–' }}</span>
|
||||
<span>Position: {{ $item->content['position'] ?? 25 }}%</span>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -16,6 +16,12 @@ test('page is accessible without login', function () {
|
|||
->assertSeeLivewire(QuickStatus::class);
|
||||
});
|
||||
|
||||
test('page accepts key query parameter used in admin link', function () {
|
||||
$this->get('/info/status?key='.VALID_KEY)
|
||||
->assertSuccessful()
|
||||
->assertSeeLivewire(QuickStatus::class);
|
||||
});
|
||||
|
||||
test('page shows forbidden when key is missing', function () {
|
||||
Livewire::test(QuickStatus::class, ['k' => ''])
|
||||
->assertSet('authorized', false)
|
||||
|
|
@ -67,10 +73,11 @@ test('save persists status and headline to database', function () {
|
|||
expect($settings->notice_subtext)->toBe('Heute nur bis 14 Uhr');
|
||||
});
|
||||
|
||||
test('save clears headline when switching to auto', function () {
|
||||
test('save preserves headline when switching to auto', function () {
|
||||
CabinetTabletSetting::factory()->create([
|
||||
'store_status' => 'notice',
|
||||
'notice_headline' => 'Alter Text',
|
||||
'notice_subtext' => 'Alter Subtext',
|
||||
]);
|
||||
|
||||
Livewire::test(QuickStatus::class, ['k' => VALID_KEY])
|
||||
|
|
@ -78,8 +85,24 @@ test('save clears headline when switching to auto', function () {
|
|||
->call('save')
|
||||
->assertHasNoErrors();
|
||||
|
||||
expect(CabinetTabletSetting::current()->store_status)->toBe('auto');
|
||||
expect(CabinetTabletSetting::current()->notice_headline)->toBeNull();
|
||||
$settings = CabinetTabletSetting::current();
|
||||
expect($settings->store_status)->toBe('auto');
|
||||
expect($settings->notice_headline)->toBe('Alter Text');
|
||||
expect($settings->notice_subtext)->toBe('Alter Subtext');
|
||||
});
|
||||
|
||||
test('switching back to notice restores saved headline from database', function () {
|
||||
CabinetTabletSetting::factory()->create([
|
||||
'store_status' => 'notice',
|
||||
'notice_headline' => 'Gespeicherter Hinweis',
|
||||
'notice_subtext' => 'Gespeicherter Subtext',
|
||||
]);
|
||||
|
||||
Livewire::test(QuickStatus::class, ['k' => VALID_KEY])
|
||||
->call('selectStatus', 'auto')
|
||||
->call('selectStatus', 'notice')
|
||||
->assertSet('noticeHeadline', 'Gespeicherter Hinweis')
|
||||
->assertSet('noticeSubtext', 'Gespeicherter Subtext');
|
||||
});
|
||||
|
||||
test('save validates headline max length', function () {
|
||||
|
|
|
|||
|
|
@ -91,6 +91,23 @@ it('returns display name from title or filename', function () {
|
|||
expect($withoutTitle->getDisplayName())->toBe('file.mp4');
|
||||
});
|
||||
|
||||
it('reports whether media dimensions match the portrait display format', function () {
|
||||
$portrait = DisplayMedia::factory()->create([
|
||||
'metadata' => ['width' => 1080, 'height' => 1920],
|
||||
]);
|
||||
$landscape = DisplayMedia::factory()->create([
|
||||
'metadata' => ['width' => 1920, 'height' => 1080],
|
||||
]);
|
||||
$unknown = DisplayMedia::factory()->create(['metadata' => null]);
|
||||
|
||||
expect($portrait->getDisplayFormatStatus())
|
||||
->toMatchArray(['status' => 'match', 'label' => 'Format passt'])
|
||||
->and($landscape->getDisplayFormatStatus())
|
||||
->toMatchArray(['status' => 'mismatch', 'label' => 'Format passt nicht'])
|
||||
->and($unknown->getDisplayFormatStatus())
|
||||
->toMatchArray(['status' => 'unknown', 'label' => 'Format unbekannt']);
|
||||
});
|
||||
|
||||
it('returns the stored poster as thumbnail url for a video', function () {
|
||||
$media = DisplayMedia::factory()->video()->create([
|
||||
'thumbnail_path' => 'display-media/2026/05/tour-poster.jpg',
|
||||
|
|
@ -229,11 +246,69 @@ it('generates a poster thumbnail when a real video is uploaded', function () {
|
|||
$media = $service->storeUpload($file);
|
||||
|
||||
expect($media->thumbnail_path)->not->toBeNull();
|
||||
expect($media->metadata)->toMatchArray(['width' => 320, 'height' => 240]);
|
||||
expect(Storage::disk('public')->exists($media->thumbnail_path))->toBeTrue();
|
||||
|
||||
@unlink($videoPath);
|
||||
});
|
||||
|
||||
it('throws and creates no record when the file cannot be written to storage', function () {
|
||||
$disk = Mockery::mock(\Illuminate\Contracts\Filesystem\Filesystem::class);
|
||||
$disk->shouldReceive('putFileAs')->once()->andReturn(false);
|
||||
Storage::shouldReceive('disk')->with('public')->andReturn($disk);
|
||||
|
||||
$service = app(DisplayMediaService::class);
|
||||
$file = UploadedFile::fake()->image('broken.jpg');
|
||||
|
||||
expect(fn () => $service->storeUpload($file))->toThrow(RuntimeException::class);
|
||||
expect(DisplayMedia::count())->toBe(0);
|
||||
});
|
||||
|
||||
it('throws and creates no record when the stored file does not exist afterwards', function () {
|
||||
$disk = Mockery::mock(\Illuminate\Contracts\Filesystem\Filesystem::class);
|
||||
$disk->shouldReceive('putFileAs')->once()->andReturn('display-media/2026/07/ghost.jpg');
|
||||
$disk->shouldReceive('exists')->once()->andReturn(false);
|
||||
Storage::shouldReceive('disk')->with('public')->andReturn($disk);
|
||||
|
||||
$service = app(DisplayMediaService::class);
|
||||
$file = UploadedFile::fake()->image('ghost.jpg');
|
||||
|
||||
expect(fn () => $service->storeUpload($file))->toThrow(RuntimeException::class);
|
||||
expect(DisplayMedia::count())->toBe(0);
|
||||
});
|
||||
|
||||
it('shows a validation error in the media library when the storage write fails', function () {
|
||||
$this->mock(DisplayMediaService::class)
|
||||
->shouldReceive('storeUpload')
|
||||
->once()
|
||||
->andThrow(new RuntimeException('Die Datei "broken.jpg" konnte nicht im Storage gespeichert werden. Bitte Schreibrechte und Speicherplatz prüfen.'));
|
||||
|
||||
$file = UploadedFile::fake()->image('broken.jpg');
|
||||
|
||||
Volt::test('admin.cms.display-media-library')
|
||||
->set('uploads', [$file])
|
||||
->call('handleUploads')
|
||||
->assertHasErrors('uploads');
|
||||
|
||||
expect(DisplayMedia::count())->toBe(0);
|
||||
});
|
||||
|
||||
it('shows a validation error in the media picker when the storage write fails', function () {
|
||||
$this->mock(DisplayMediaService::class)
|
||||
->shouldReceive('storeUpload')
|
||||
->once()
|
||||
->andThrow(new RuntimeException('Die Datei "broken.jpg" konnte nicht im Storage gespeichert werden. Bitte Schreibrechte und Speicherplatz prüfen.'));
|
||||
|
||||
$file = UploadedFile::fake()->image('broken.jpg');
|
||||
|
||||
Livewire\Livewire::test(\App\Livewire\Admin\Cms\DisplayMediaPicker::class)
|
||||
->set('quickUploads', [$file])
|
||||
->assertHasErrors('quickUploads')
|
||||
->assertNotDispatched('display-media-selected');
|
||||
|
||||
expect(DisplayMedia::count())->toBe(0);
|
||||
});
|
||||
|
||||
it('does not set a thumbnail when the video cannot be decoded', function () {
|
||||
$service = app(DisplayMediaService::class);
|
||||
$file = UploadedFile::fake()->create('broken.mp4', 500, 'video/mp4');
|
||||
|
|
|
|||
|
|
@ -176,6 +176,7 @@ test('returns playlist with offers config', function () {
|
|||
'bullets' => [],
|
||||
'disclaimer' => '',
|
||||
'qr_url' => 'https://cabinet-bielefeld.de',
|
||||
'qr_image_url' => 'https://portal.b2in.test/storage/display-qr-codes/cabinet.png',
|
||||
'qr_title' => 'Reservieren',
|
||||
'contact' => "0521 98620100\nTel. oder WhatsApp",
|
||||
'show_brand_text' => false,
|
||||
|
|
@ -193,6 +194,7 @@ test('returns playlist with offers config', function () {
|
|||
$response->assertJsonPath('playlist.0.slides.0.title', 'GOYA Sideboard');
|
||||
$response->assertJsonPath('playlist.0.slides.0.price', '489 €');
|
||||
$response->assertJsonPath('playlist.0.slides.0.qr_url', 'https://cabinet-bielefeld.de');
|
||||
$response->assertJsonPath('playlist.0.slides.0.qr_image_url', 'https://portal.b2in.test/storage/display-qr-codes/cabinet.png');
|
||||
});
|
||||
|
||||
test('returns playlist with multiple versions in order', function () {
|
||||
|
|
@ -442,6 +444,15 @@ test('display player keeps previews in a strict 9 by 16 viewport', function () {
|
|||
->toContain('container-type: size;')
|
||||
->toContain("if (hostname === 'cabinet.b2in.eu') {")
|
||||
->toContain("return 'https://portal.b2in.eu';")
|
||||
->toContain('startConfigPolling')
|
||||
->toContain('startFileVersionPolling')
|
||||
->toContain('playerFileCheckUrl')
|
||||
->toContain('readPlayerFileVersion')
|
||||
->toContain('Player-Datei geändert')
|
||||
->toContain('Playlist geändert')
|
||||
->toContain('DISPLAY_PORTAL_BASE_URL')
|
||||
->toContain("url.startsWith('/_cabinet/')")
|
||||
->toContain('`${DISPLAY_PORTAL_BASE_URL}${url}`')
|
||||
->toContain('/api/display/overview')
|
||||
->toContain('Aktive Live-Displays')
|
||||
->toContain('renderOverview(data.displays || [])')
|
||||
|
|
@ -449,7 +460,25 @@ test('display player keeps previews in a strict 9 by 16 viewport', function () {
|
|||
->toContain('this.settings.footer_claim')
|
||||
->toContain('this.settings.footer_url')
|
||||
->toContain('this.settings.header_logo_url')
|
||||
->toContain('/_cabinet/assets/b2in-logo-positive.svg')
|
||||
->toContain("url.startsWith('../assets/')")
|
||||
->toContain("!url.includes('/') && url.endsWith('.svg')")
|
||||
->toContain('this.settings.qr_label')
|
||||
->toContain('function setQrImage')
|
||||
->toContain('function qrCodeImageUrl')
|
||||
->toContain('options.imageUrl')
|
||||
->toContain('content.qr_image_url')
|
||||
->toContain('this.settings.qr_image_url')
|
||||
->toContain('slide.qr_image_url')
|
||||
->toContain("img.loading = 'eager'")
|
||||
->toContain('maxAttempts')
|
||||
->toContain('cb=${Date.now()}-${attempt}')
|
||||
->toContain('offer-slide--no-header')
|
||||
->toContain('offer-slide--no-bottom')
|
||||
->toContain('offer-bottom--info-only')
|
||||
->toContain('offer-bottom--qr-only')
|
||||
->toContain('offer-info--compact')
|
||||
->toContain('const showBottom = showInfo || showQrBox')
|
||||
->toContain('this.settings.show_footer !== false')
|
||||
->toContain('this.settings.show_logo !== false')
|
||||
->toContain('this.settings.show_claim !== false')
|
||||
|
|
@ -457,7 +486,17 @@ test('display player keeps previews in a strict 9 by 16 viewport', function () {
|
|||
->toContain('this.settings.claim_position')
|
||||
->toContain('b2in-brand-logo pos-')
|
||||
->toContain('b2in-scrim')
|
||||
->toContain('.b2in-layer.no-footer .b2in-text');
|
||||
->toContain('.b2in-layer.no-footer .b2in-text')
|
||||
->toContain('background: none');
|
||||
});
|
||||
|
||||
test('display player skips broken images instead of showing a black frame', function () {
|
||||
$player = file_get_contents(public_path('_cabinet/display/index.html'));
|
||||
|
||||
expect($player)
|
||||
->toContain('img.onerror = () => {')
|
||||
->toContain('[B2in] Image error:')
|
||||
->toContain('setTimeout(finish, 2000);');
|
||||
});
|
||||
|
||||
test('display player stops looping when the playlist has no playable content', function () {
|
||||
|
|
@ -489,10 +528,12 @@ test('preview player is served with a revalidation cache header', function () {
|
|||
$display = Display::factory()->create(['preview_token' => 'fresh-token']);
|
||||
$version = DisplayVersion::factory()->create();
|
||||
|
||||
foreach ([
|
||||
foreach (
|
||||
[
|
||||
'/preview/fresh-token',
|
||||
"/preview/module/{$version->id}",
|
||||
] as $url) {
|
||||
] as $url
|
||||
) {
|
||||
$response = $this->get($url);
|
||||
$response->assertSuccessful();
|
||||
expect($response->headers->get('Cache-Control'))->toContain('no-cache');
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ use App\Models\DisplayPlaylistItem;
|
|||
use App\Models\DisplayVersion;
|
||||
use App\Models\DisplayVersionItem;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Livewire\Livewire;
|
||||
|
||||
|
|
@ -70,6 +72,7 @@ test('can create a display version', function () {
|
|||
expect($version->settings)->toBeArray();
|
||||
expect($version->settings)->toHaveKey('theme');
|
||||
expect($version->settings)->toHaveKey('header_logo_url');
|
||||
expect($version->settings['header_logo_url'])->toBe('/_cabinet/assets/b2in-logo-positive.svg');
|
||||
expect($version->settings)->toHaveKey('footer_url');
|
||||
});
|
||||
|
||||
|
|
@ -129,6 +132,65 @@ test('can toggle display version active status', function () {
|
|||
expect($version->fresh()->is_active)->toBeFalse();
|
||||
});
|
||||
|
||||
test('can copy a display version with all items and settings', function () {
|
||||
$user = User::factory()->create();
|
||||
$source = DisplayVersion::factory()->create([
|
||||
'name' => 'B2In Immobilien Dark',
|
||||
'type' => DisplayVersionType::B2in,
|
||||
'settings' => ['theme' => 'dark', 'show_footer' => false],
|
||||
'is_active' => true,
|
||||
]);
|
||||
DisplayVersionItem::factory()->create([
|
||||
'display_version_id' => $source->id,
|
||||
'item_type' => 'media',
|
||||
'content' => ['headline' => 'Test Headline'],
|
||||
'sort_order' => 0,
|
||||
]);
|
||||
DisplayVersionItem::factory()->create([
|
||||
'display_version_id' => $source->id,
|
||||
'item_type' => 'media',
|
||||
'content' => ['headline' => 'Zweites Medium'],
|
||||
'sort_order' => 1,
|
||||
]);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(DisplayVersionList::class)
|
||||
->call('copyVersion', $source->id);
|
||||
|
||||
$copy = DisplayVersion::query()->where('name', 'Kopie 1 - B2In Immobilien Dark')->first();
|
||||
|
||||
expect($copy)->not->toBeNull()
|
||||
->and($copy->type)->toBe(DisplayVersionType::B2in)
|
||||
->and($copy->settings)->toBe(['theme' => 'dark', 'show_footer' => false])
|
||||
->and($copy->is_active)->toBeTrue()
|
||||
->and($copy->items)->toHaveCount(2)
|
||||
->and($copy->items->pluck('content.headline')->all())->toBe(['Test Headline', 'Zweites Medium']);
|
||||
});
|
||||
|
||||
test('copying a display version increments the copy number', function () {
|
||||
$user = User::factory()->create();
|
||||
$source = DisplayVersion::factory()->create(['name' => 'Schaufenster Video']);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(DisplayVersionList::class)
|
||||
->call('copyVersion', $source->id)
|
||||
->call('copyVersion', $source->id);
|
||||
|
||||
expect(DisplayVersion::where('name', 'Kopie 1 - Schaufenster Video')->exists())->toBeTrue()
|
||||
->and(DisplayVersion::where('name', 'Kopie 2 - Schaufenster Video')->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
test('copying an existing copy keeps the original base name in the title', function () {
|
||||
$user = User::factory()->create();
|
||||
$copy = DisplayVersion::factory()->create(['name' => 'Kopie 1 - Angebote Schauraum']);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(DisplayVersionList::class)
|
||||
->call('copyVersion', $copy->id);
|
||||
|
||||
expect(DisplayVersion::where('name', 'Kopie 2 - Angebote Schauraum')->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// DisplayVersionEditor
|
||||
// ========================================
|
||||
|
|
@ -446,6 +508,56 @@ test('can add a slide item to offers version', function () {
|
|||
expect($item->content['qr_url'])->toBe('https://cabinet-bielefeld.de');
|
||||
});
|
||||
|
||||
test('slide editor generates and stores a qr image when a qr url is saved', function () {
|
||||
Storage::fake('public');
|
||||
Http::fake([
|
||||
'api.qrserver.com/*' => Http::response('fake-png', 200, ['Content-Type' => 'image/png']),
|
||||
]);
|
||||
|
||||
$user = User::factory()->create();
|
||||
$version = DisplayVersion::factory()->create(['type' => 'offers']);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(DisplayVersionEditor::class, ['displayVersion' => $version])
|
||||
->call('openItemModal', null, 'slide')
|
||||
->set('slideTitle', 'QR Slide')
|
||||
->set('slideQrUrl', 'cabinet-bielefeld.de')
|
||||
->call('saveItem');
|
||||
|
||||
$item = DisplayVersionItem::where('display_version_id', $version->id)->first();
|
||||
|
||||
expect($item->content['qr_url'])->toBe('cabinet-bielefeld.de')
|
||||
->and($item->content['qr_image_path'])->toStartWith('display-qr-codes/')
|
||||
->and($item->content['qr_image_url'])->toContain('/storage/display-qr-codes/');
|
||||
|
||||
expect(Storage::disk('public')->exists($item->content['qr_image_path']))->toBeTrue();
|
||||
Http::assertSentCount(1);
|
||||
});
|
||||
|
||||
test('b2in settings generate and store a qr image when a qr url is saved', function () {
|
||||
Storage::fake('public');
|
||||
Http::fake([
|
||||
'api.qrserver.com/*' => Http::response('fake-png', 200, ['Content-Type' => 'image/png']),
|
||||
]);
|
||||
|
||||
$user = User::factory()->create();
|
||||
$version = DisplayVersion::factory()->create(['type' => 'b2in']);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(DisplayVersionEditor::class, ['displayVersion' => $version])
|
||||
->call('openSettingsModal')
|
||||
->set('settings.qr_url', 'b2in.eu')
|
||||
->call('saveSettings');
|
||||
|
||||
$settings = $version->fresh()->settings;
|
||||
|
||||
expect($settings['qr_image_path'])->toStartWith('display-qr-codes/')
|
||||
->and($settings['qr_image_url'])->toContain('/storage/display-qr-codes/');
|
||||
|
||||
expect(Storage::disk('public')->exists($settings['qr_image_path']))->toBeTrue();
|
||||
Http::assertSentCount(1);
|
||||
});
|
||||
|
||||
test('can add a slide with bullets to offers version', function () {
|
||||
$user = User::factory()->create();
|
||||
$version = DisplayVersion::factory()->create(['type' => 'offers']);
|
||||
|
|
@ -644,6 +756,53 @@ test('selecting an image medium auto-sets the media type to image', function ()
|
|||
->assertSet('mediaType', 'image');
|
||||
});
|
||||
|
||||
test('b2in media editor shows a matching portrait format badge', function () {
|
||||
$user = User::factory()->create();
|
||||
$version = DisplayVersion::factory()->create(['type' => 'b2in']);
|
||||
$media = DisplayMedia::factory()->create([
|
||||
'type' => 'image',
|
||||
'metadata' => ['width' => 1080, 'height' => 1920],
|
||||
]);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(DisplayVersionEditor::class, ['displayVersion' => $version])
|
||||
->call('openItemModal', null, 'media')
|
||||
->call('onDisplayMediaSelected', 'mediaUrl', $media->id, $media->getUrl())
|
||||
->assertSee('Format passt')
|
||||
->assertSee('1080x1920 px');
|
||||
});
|
||||
|
||||
test('b2in media editor warns for landscape media', function () {
|
||||
$user = User::factory()->create();
|
||||
$version = DisplayVersion::factory()->create(['type' => 'b2in']);
|
||||
$media = DisplayMedia::factory()->create([
|
||||
'type' => 'image',
|
||||
'metadata' => ['width' => 1920, 'height' => 1080],
|
||||
]);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(DisplayVersionEditor::class, ['displayVersion' => $version])
|
||||
->call('openItemModal', null, 'media')
|
||||
->call('onDisplayMediaSelected', 'mediaUrl', $media->id, $media->getUrl())
|
||||
->assertSee('Format passt nicht')
|
||||
->assertSee('1920x1080 px');
|
||||
});
|
||||
|
||||
test('video display editor warns for non portrait video media', function () {
|
||||
$user = User::factory()->create();
|
||||
$version = DisplayVersion::factory()->create(['type' => 'video-display']);
|
||||
$media = DisplayMedia::factory()->video()->create([
|
||||
'metadata' => ['width' => 1920, 'height' => 1080],
|
||||
]);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(DisplayVersionEditor::class, ['displayVersion' => $version])
|
||||
->call('openItemModal', null, 'video')
|
||||
->call('onDisplayMediaSelected', 'videoFilename', $media->id, $media->getUrl())
|
||||
->assertSee('Format passt nicht')
|
||||
->assertSee('1920x1080 px');
|
||||
});
|
||||
|
||||
test('media item modal offers the sonstiges category and an automatic type hint', function () {
|
||||
$user = User::factory()->create();
|
||||
$version = DisplayVersion::factory()->create(['type' => 'b2in']);
|
||||
|
|
|
|||
31
tests/Feature/FluxAssetRouteTest.php
Normal file
31
tests/Feature/FluxAssetRouteTest.php
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Support\Facades\URL;
|
||||
|
||||
beforeEach(function () {
|
||||
URL::forceRootUrl('https://'.config('domains.domain_portal'));
|
||||
});
|
||||
|
||||
test('portal domain serves flux javascript from laravel route', function () {
|
||||
$this->get('https://'.config('domains.domain_portal').'/flux/flux.js?id=test')
|
||||
->assertSuccessful()
|
||||
->assertHeader('content-type', 'text/javascript; charset=utf-8');
|
||||
});
|
||||
|
||||
test('portal domain serves minified flux javascript route in production mode', function () {
|
||||
config(['app.debug' => false]);
|
||||
|
||||
$this->get('https://'.config('domains.domain_portal').'/flux/flux.min.js?id=test')
|
||||
->assertSuccessful()
|
||||
->assertHeader('content-type', 'text/javascript; charset=utf-8');
|
||||
});
|
||||
|
||||
test('flux pro vendor bundle contains alpine components used by the admin ui', function () {
|
||||
$path = base_path('vendor/livewire/flux-pro/dist/flux.js');
|
||||
|
||||
expect(is_file($path))->toBeTrue('Flux Pro must be installed via composer (auth.json on live).');
|
||||
|
||||
expect(file_get_contents($path))->toContain('fluxModal');
|
||||
});
|
||||
30
tests/Feature/PublishFluxAssetsTest.php
Normal file
30
tests/Feature/PublishFluxAssetsTest.php
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
test('flux publish assets command copies javascript into public flux directory', function () {
|
||||
$targetDir = public_path('flux');
|
||||
|
||||
if (File::isDirectory($targetDir)) {
|
||||
File::deleteDirectory($targetDir);
|
||||
}
|
||||
|
||||
$this->artisan('flux:publish-assets')
|
||||
->assertSuccessful();
|
||||
|
||||
expect(is_file(public_path('flux/flux.js')))->toBeTrue();
|
||||
expect(is_file(public_path('flux/flux.min.js')))->toBeTrue();
|
||||
expect(filesize(public_path('flux/flux.min.js')))->toBeGreaterThan(1000);
|
||||
|
||||
File::deleteDirectory($targetDir);
|
||||
});
|
||||
|
||||
test('composer install and update publish flux assets', function () {
|
||||
$composer = json_decode((string) file_get_contents(base_path('composer.json')), true, flags: JSON_THROW_ON_ERROR);
|
||||
$expectedCommand = '@php artisan flux:publish-assets --ansi --force';
|
||||
|
||||
expect($composer['scripts']['post-install-cmd'] ?? [])->toContain($expectedCommand);
|
||||
expect($composer['scripts']['post-update-cmd'] ?? [])->toContain($expectedCommand);
|
||||
});
|
||||
28
tests/Unit/DisplayModuleSettingsTest.php
Normal file
28
tests/Unit/DisplayModuleSettingsTest.php
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Enums\DisplayVersionType;
|
||||
use App\Support\DisplayModuleSettings;
|
||||
|
||||
test('merge normalizes b2in boolean settings from legacy scalar values', function () {
|
||||
$merged = DisplayModuleSettings::merge(DisplayVersionType::B2in, [
|
||||
'show_logo' => 1,
|
||||
'show_claim' => 0,
|
||||
'show_footer' => 'true',
|
||||
'display_active' => 'false',
|
||||
]);
|
||||
|
||||
expect($merged['show_logo'])->toBeTrue()
|
||||
->and($merged['show_claim'])->toBeFalse()
|
||||
->and($merged['show_footer'])->toBeTrue()
|
||||
->and($merged['display_active'])->toBeFalse();
|
||||
});
|
||||
|
||||
test('merge normalizes offers loop boolean', function () {
|
||||
$merged = DisplayModuleSettings::merge(DisplayVersionType::Offers, [
|
||||
'loop' => 0,
|
||||
]);
|
||||
|
||||
expect($merged['loop'])->toBeFalse();
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue