Display CMS Optimierungen 03-06-2026

This commit is contained in:
Kevin Adametz 2026-06-03 15:43:18 +02:00
parent 6c6d683b9a
commit bebfa63e87
17 changed files with 925 additions and 133 deletions

1
.gitignore vendored
View file

@ -5,6 +5,7 @@
/public/hot
/public/storage
/public/vendor
/public/flux
/storage/*.key
/storage/app
/storage/framework

View 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.');
}
}

View file

@ -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();
@ -463,7 +465,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 +516,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

View file

@ -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;

View file

@ -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'] ?? '',

View 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,
]);
}
}

View file

@ -19,7 +19,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',

View file

@ -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');\""
],

View 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`*

View file

@ -270,6 +270,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 +312,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 +327,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;
@ -546,6 +563,57 @@ function normalizeQrUrl(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 || 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
*
@ -1157,8 +1225,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';
@ -1218,7 +1285,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 +1294,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 +1337,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 +1368,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() {
@ -1409,6 +1482,8 @@ class B2inRenderer {
if (!url) return '';
if (url.startsWith('http')) return 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}`;
}
@ -1512,12 +1587,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 +1631,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,101 +1650,114 @@ 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
const info = document.createElement('div');
info.className = 'offer-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';
const infoContent = document.createElement('div');
infoContent.className = 'offer-info-content';
if (show.eyebrow) {
const eyebrow = document.createElement('p');
eyebrow.className = 'offer-eyebrow';
eyebrow.textContent = slide.eyebrow;
infoContent.appendChild(eyebrow);
}
if (slide.title) {
const title = document.createElement('h1');
title.className = 'offer-title medium';
title.innerHTML = slide.title.replace(/\n/g, '<br>');
infoContent.appendChild(title);
}
if (show.subline) {
const subline = document.createElement('p');
subline.className = 'offer-subline';
subline.textContent = slide.subline;
infoContent.appendChild(subline);
}
if (show.bullets) {
const ul = document.createElement('ul');
ul.className = 'offer-bullets';
slide.bullets.forEach(text => {
if (!text) return;
const li = document.createElement('li');
li.innerHTML = `<span class="offer-bullet-dot"></span><span>${this.escapeHtml(text)}</span>`;
ul.appendChild(li);
});
infoContent.appendChild(ul);
}
info.appendChild(infoContent);
// Price block
if (show.price) {
const priceBlock = document.createElement('div');
priceBlock.className = 'offer-price-block';
const priceRow = document.createElement('div');
priceRow.className = 'offer-price-row';
const price = document.createElement('span');
price.className = 'offer-price';
price.textContent = slide.price;
priceRow.appendChild(price);
if (slide.original_price) {
const note = document.createElement('div');
note.className = slide.strike_original_price ? 'offer-price-note strike' : 'offer-price-note';
note.textContent = slide.original_price;
priceRow.appendChild(note);
if (show.eyebrow) {
const eyebrow = document.createElement('p');
eyebrow.className = 'offer-eyebrow';
eyebrow.textContent = slide.eyebrow;
infoContent.appendChild(eyebrow);
}
if (slide.tag_text) {
const note = document.createElement('div');
note.className = 'offer-price-note';
const tag = document.createElement('span');
tag.className = 'offer-impulse-tag';
tag.textContent = slide.tag_text;
note.appendChild(tag);
priceRow.appendChild(note);
if (slide.title) {
const title = document.createElement('h1');
title.className = 'offer-title medium';
title.innerHTML = slide.title.replace(/\n/g, '<br>');
infoContent.appendChild(title);
}
priceBlock.appendChild(priceRow);
info.appendChild(priceBlock);
}
if (show.subline) {
const subline = document.createElement('p');
subline.className = 'offer-subline';
subline.textContent = slide.subline;
infoContent.appendChild(subline);
}
// Disclaimer
if (show.disclaimer) {
const footer = document.createElement('div');
footer.className = 'offer-info-footer';
const disc = document.createElement('span');
disc.className = 'offer-disclaimer';
disc.textContent = slide.disclaimer;
footer.appendChild(disc);
info.appendChild(footer);
}
if (show.bullets) {
const ul = document.createElement('ul');
ul.className = 'offer-bullets';
slide.bullets.forEach(text => {
if (!text) return;
const li = document.createElement('li');
li.innerHTML = `<span class="offer-bullet-dot"></span><span>${this.escapeHtml(text)}</span>`;
ul.appendChild(li);
});
infoContent.appendChild(ul);
}
bottom.appendChild(info);
info.appendChild(infoContent);
// Price block
if (show.price) {
const priceBlock = document.createElement('div');
priceBlock.className = 'offer-price-block';
const priceRow = document.createElement('div');
priceRow.className = 'offer-price-row';
const price = document.createElement('span');
price.className = 'offer-price';
price.textContent = slide.price;
priceRow.appendChild(price);
if (slide.original_price) {
const note = document.createElement('div');
note.className = slide.strike_original_price ? 'offer-price-note strike' : 'offer-price-note';
note.textContent = slide.original_price;
priceRow.appendChild(note);
}
if (slide.tag_text) {
const note = document.createElement('div');
note.className = 'offer-price-note';
const tag = document.createElement('span');
tag.className = 'offer-impulse-tag';
tag.textContent = slide.tag_text;
note.appendChild(tag);
priceRow.appendChild(note);
}
priceBlock.appendChild(priceRow);
info.appendChild(priceBlock);
}
// Disclaimer
if (show.disclaimer) {
const footer = document.createElement('div');
footer.className = 'offer-info-footer';
const disc = document.createElement('span');
disc.className = 'offer-disclaimer';
disc.textContent = slide.disclaimer;
footer.appendChild(disc);
info.appendChild(footer);
}
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 +1773,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 +1794,6 @@ class OffersRenderer {
}
bottom.appendChild(qrBox);
} else {
bottom.style.gridTemplateColumns = '1fr';
}
article.appendChild(bottom);
@ -1757,6 +1854,8 @@ class OffersRenderer {
if (!url) return '';
if (url.startsWith('http')) return 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}`;
}

View file

@ -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,9 +40,8 @@
</x-success-alert>
@endif
<form wire:submit.prevent="save"
x-data
x-on:submit.prevent="
<form wire:submit.prevent="save" x-data
x-on:submit.prevent="
const hours = {};
document.querySelectorAll('[data-hours-prop]').forEach(el => {
const prop = el.dataset.hoursProp;
@ -54,20 +64,28 @@
<option value="closed">Manuell geschlossen (Gelb)</option>
</flux:select>
@if($storeStatus !== 'auto')
@if ($storeStatus !== 'auto')
@php
$noticeColors = match($storeStatus) {
'notice' => 'bg-orange-50 dark:bg-orange-900/20 border border-orange-200 dark:border-orange-800',
$noticeColors = match ($storeStatus) {
'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>
@ -111,7 +127,7 @@
<flux:card>
<div class="flex items-center justify-between mb-4">
<flux:heading size="lg">{{ __('Sonderöffnung heute') }}</flux:heading>
@if($overrideOpenToday || $overrideCloseToday)
@if ($overrideOpenToday || $overrideCloseToday)
<flux:button wire:click="clearOverrides" size="sm" variant="ghost" icon="x-mark">
{{ __('Zurücksetzen') }}
</flux:button>
@ -126,7 +142,7 @@
<flux:time-picker wire:model="overrideCloseToday" label="Schluss" time-format="24-hour" interval="15" clearable placeholder="–" />
@error('overrideCloseToday') <span class="text-red-600 text-sm">{{ $message }}</span> @enderror
</div>
</flux:card>--}}
</flux:card> --}}
{{-- Nächster Termin
<flux:card>
@ -139,7 +155,7 @@
<flux:time-picker wire:model="nextAppointmentTime" label="Uhrzeit" time-format="24-hour" interval="15" clearable placeholder="–" />
@error('nextAppointmentTime') <span class="text-red-600 text-sm">{{ $message }}</span> @enderror
</div>
</flux:card>--}}
</flux:card> --}}
{{-- Kontakt --}}
<flux:card>
@ -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>

View file

@ -27,7 +27,7 @@
: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" />

View file

@ -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)

View file

@ -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 () {
@ -449,7 +451,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')

View file

@ -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');
});
@ -446,6 +449,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']);

View 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');
});

View 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);
});